quonfig 0.0.5 → 0.0.8
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 +46 -0
- data/README.md +36 -0
- data/VERSION +1 -1
- data/lib/quonfig/client.rb +132 -2
- data/lib/quonfig/context.rb +10 -1
- data/lib/quonfig/datadir.rb +2 -4
- data/lib/quonfig/errors/decryption_error.rb +20 -0
- data/lib/quonfig/errors/env_var_parse_error.rb +8 -1
- data/lib/quonfig/errors/invalid_environment_error.rb +19 -0
- data/lib/quonfig/errors/missing_environment_error.rb +18 -0
- data/lib/quonfig/evaluator.rb +64 -2
- data/lib/quonfig/http_connection.rb +1 -1
- data/lib/quonfig/resolver.rb +187 -2
- data/lib/quonfig/stdlib_formatter.rb +95 -0
- data/lib/quonfig/telemetry/context_shape.rb +33 -0
- data/lib/quonfig/telemetry/context_shape_aggregator.rb +82 -0
- data/lib/quonfig/telemetry/evaluation_summaries_aggregator.rb +119 -0
- data/lib/quonfig/telemetry/example_contexts_aggregator.rb +101 -0
- data/lib/quonfig/telemetry/telemetry_reporter.rb +200 -0
- data/lib/quonfig.rb +9 -0
- data/quonfig.gemspec +20 -4
- data/test/integration/test_context_precedence.rb +35 -117
- data/test/integration/test_datadir_environment.rb +15 -37
- data/test/integration/test_enabled.rb +157 -463
- data/test/integration/test_enabled_with_contexts.rb +19 -49
- data/test/integration/test_get.rb +43 -131
- data/test/integration/test_get_feature_flag.rb +7 -13
- data/test/integration/test_get_or_raise.rb +19 -45
- data/test/integration/test_get_weighted_values.rb +9 -4
- data/test/integration/test_helpers.rb +499 -4
- data/test/integration/test_post.rb +15 -5
- data/test/integration/test_telemetry.rb +63 -21
- data/test/test_client_telemetry.rb +132 -0
- data/test/test_context.rb +4 -1
- data/test/test_context_shape.rb +37 -0
- data/test/test_context_shape_aggregator.rb +126 -0
- data/test/test_datadir.rb +6 -2
- data/test/test_evaluation_summaries_aggregator.rb +180 -0
- data/test/test_example_contexts_aggregator.rb +119 -0
- data/test/test_http_connection.rb +1 -1
- data/test/test_resolver.rb +149 -2
- data/test/test_should_log.rb +186 -0
- data/test/test_stdlib_formatter.rb +195 -0
- data/test/test_telemetry_reporter.rb +209 -0
- metadata +19 -3
- data/scripts/generate_integration_tests.rb +0 -362
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 18707780a8ad33c299973f4de01bc0b76d10161902f977b9771c10c2d4a23fde
|
|
4
|
+
data.tar.gz: 6980b904632659b97f16636e9a317bc8b3e8cbf02cc0b8f75ecf51325b8ba472
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 74c6a59b81fd222ddd522e52301ea74a1f9bc76b18ecbfe50c01a46e21539bdc5cea6a38a927938fb497f6003a64a2e796d177f2ed3ee527d5891f6cd9ca63a4
|
|
7
|
+
data.tar.gz: 94b1c109e6db3df2ea115e62f80f099430752779faa9655ee6f53c20c614fa42ccdc8e350342985cb09861e286bd91b857381508849a81fada865927c98f7f22
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.8 - 2026-04-26
|
|
4
|
+
|
|
5
|
+
- **Fix (gemspec): drop deleted `scripts/` entry from manifest** — regenerated
|
|
6
|
+
juwelier gemspec so `gem build` no longer fails on the missing
|
|
7
|
+
`scripts/generate_integration_tests.rb` file. Also untracked stray
|
|
8
|
+
`.DS_Store`. v0.0.7 was tagged but never published due to this bug.
|
|
9
|
+
|
|
10
|
+
## 0.0.7 - 2026-04-26
|
|
11
|
+
|
|
12
|
+
- **New: `client.enabled?` / `client.default` / `client.client_construction` integration helpers** —
|
|
13
|
+
Adds aggregator helpers used by the cross-SDK post + telemetry integration suites.
|
|
14
|
+
- **New: telemetry eval-summaries aggregator + `at_exit` drain (qfg-9x7)** —
|
|
15
|
+
Periodically batches evaluation summaries and drains them on process exit so
|
|
16
|
+
short-lived scripts still report telemetry.
|
|
17
|
+
- **New: context telemetry aggregators ported from sdk-node** — context shapes
|
|
18
|
+
and example-contexts ship through the same aggregator path as sdk-node and
|
|
19
|
+
sdk-go.
|
|
20
|
+
- **New errors: `DecryptionError`, `MissingEnvironmentError`,
|
|
21
|
+
`InvalidEnvironmentError`** — explicit error classes raised from the resolver
|
|
22
|
+
and datadir loaders.
|
|
23
|
+
- **Resolver: provided ENV_VAR resolution + coercion (qfg-08q)** — config values
|
|
24
|
+
marked `provided` now resolve from the environment at evaluation time and are
|
|
25
|
+
coerced to the declared value type.
|
|
26
|
+
- **Fix (resolver): raise on missing key, decode weighted/duration/decryption** —
|
|
27
|
+
`get_or_raise` now raises `MissingDefaultError` for unknown keys, and weighted /
|
|
28
|
+
duration / decryption value types decode correctly through the JSON resolver.
|
|
29
|
+
- **Fix (context): `grouped_key` drops anonymous contexts** — anonymous contexts
|
|
30
|
+
are no longer mixed into the grouped-context key, matching sdk-node and sdk-go.
|
|
31
|
+
|
|
32
|
+
## 0.0.6 - 2026-04-22
|
|
33
|
+
|
|
34
|
+
- **New: `Quonfig::StdlibFormatter` + `client.stdlib_formatter(logger_name:)`** —
|
|
35
|
+
Ruby's built-in `::Logger` now gets drop-in dynamic log-level gating,
|
|
36
|
+
on par with the existing SemanticLogger integration. The client helper
|
|
37
|
+
returns a Proc matching the stdlib `logger.formatter =` contract
|
|
38
|
+
(`(severity, datetime, progname, msg) -> String`). For each log call
|
|
39
|
+
the proc evaluates `should_log?(logger_path: logger_name || progname,
|
|
40
|
+
desired_level: severity)` and either formats the record or returns an
|
|
41
|
+
empty string (which `::Logger` writes as zero bytes, suppressing the
|
|
42
|
+
line). `logger_name` flows into `quonfig-sdk-logging.key` verbatim —
|
|
43
|
+
no normalization — so customer rules target exact class names.
|
|
44
|
+
Raises `Quonfig::Error` if `logger_key` was not set at init. Parallels
|
|
45
|
+
sdk-node's Winston formatter, sdk-python's `logging.Filter`, and
|
|
46
|
+
sdk-go's `slog.Handler`. Closes Stage 2 of the per-SDK logger-path
|
|
47
|
+
rollout.
|
|
48
|
+
|
|
3
49
|
## 0.0.5 - 2026-04-22
|
|
4
50
|
|
|
5
51
|
- **BREAKING — SemanticLoggerFilter context key renamed.** The filter
|
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.8
|
data/lib/quonfig/client.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Quonfig
|
|
|
21
21
|
LOG = Quonfig::InternalLogger.new(self)
|
|
22
22
|
|
|
23
23
|
attr_reader :options, :resolver, :store, :evaluator, :instance_hash,
|
|
24
|
-
:config_loader
|
|
24
|
+
:config_loader, :telemetry_reporter
|
|
25
25
|
|
|
26
26
|
def initialize(options = nil, store: nil, **option_kwargs)
|
|
27
27
|
@options =
|
|
@@ -41,6 +41,7 @@ module Quonfig
|
|
|
41
41
|
@sse_client = nil
|
|
42
42
|
@poll_thread = nil
|
|
43
43
|
@stopped = false
|
|
44
|
+
@telemetry_reporter = nil
|
|
44
45
|
|
|
45
46
|
# If the caller injected a store, we're in test/bootstrap mode; skip I/O.
|
|
46
47
|
return if store
|
|
@@ -50,15 +51,27 @@ module Quonfig
|
|
|
50
51
|
else
|
|
51
52
|
initialize_network_mode
|
|
52
53
|
end
|
|
54
|
+
|
|
55
|
+
initialize_telemetry
|
|
53
56
|
end
|
|
54
57
|
|
|
55
58
|
# ---- Lookup --------------------------------------------------------
|
|
56
59
|
|
|
57
60
|
def get(key, default = NO_DEFAULT_PROVIDED, jit_context = NO_DEFAULT_PROVIDED)
|
|
58
61
|
ctx = build_context(jit_context)
|
|
59
|
-
|
|
62
|
+
record_context_for_telemetry(ctx)
|
|
63
|
+
result =
|
|
64
|
+
begin
|
|
65
|
+
@resolver.get(key, ctx)
|
|
66
|
+
rescue Quonfig::Errors::MissingDefaultError
|
|
67
|
+
# The Resolver raises (matching Quonfig.get_or_raise semantics).
|
|
68
|
+
# The Client's get applies the caller-provided default *or* the
|
|
69
|
+
# configured on_no_default policy via handle_missing.
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
60
72
|
return handle_missing(key, default) if result.nil?
|
|
61
73
|
|
|
74
|
+
record_evaluation_for_telemetry(result)
|
|
62
75
|
result.unwrapped_value
|
|
63
76
|
end
|
|
64
77
|
|
|
@@ -125,6 +138,29 @@ module Quonfig
|
|
|
125
138
|
Quonfig::SemanticLoggerFilter.new(self, config_key: config_key)
|
|
126
139
|
end
|
|
127
140
|
|
|
141
|
+
# Build a formatter Proc for Ruby's built-in +::Logger+. The returned
|
|
142
|
+
# proc honors dynamic log levels from the client's +logger_key+ config:
|
|
143
|
+
# for each log call, it evaluates +should_log?+ and either formats the
|
|
144
|
+
# record or returns an empty string (suppressing output).
|
|
145
|
+
#
|
|
146
|
+
# Matches ReforgeHQ's +stdlib_formatter+ API name (snake_case).
|
|
147
|
+
#
|
|
148
|
+
# Usage:
|
|
149
|
+
# logger = ::Logger.new($stdout)
|
|
150
|
+
# logger.formatter = client.stdlib_formatter # uses progname
|
|
151
|
+
# logger.formatter = client.stdlib_formatter(logger_name: 'MyApp') # fixed name
|
|
152
|
+
#
|
|
153
|
+
# Raises +Quonfig::Error+ if +logger_key+ was not set at init — parallels
|
|
154
|
+
# +should_log?+'s behavior.
|
|
155
|
+
#
|
|
156
|
+
# @param logger_name [String, nil] fallback logger identifier used when
|
|
157
|
+
# +progname+ isn't supplied by the Logger call site. If both are
|
|
158
|
+
# present, +logger_name+ wins.
|
|
159
|
+
# @return [Proc] a +(severity, datetime, progname, msg) -> String+ proc.
|
|
160
|
+
def stdlib_formatter(logger_name: nil)
|
|
161
|
+
Quonfig::StdlibFormatter.build(self, logger_name: logger_name)
|
|
162
|
+
end
|
|
163
|
+
|
|
128
164
|
# The configured +logger_key+ from Options — the Quonfig config key the
|
|
129
165
|
# higher-level +should_log?+ helper evaluates per-logger. +nil+ if the
|
|
130
166
|
# client was not configured for dynamic log levels.
|
|
@@ -200,6 +236,13 @@ module Quonfig
|
|
|
200
236
|
thread = @poll_thread
|
|
201
237
|
@poll_thread = nil
|
|
202
238
|
thread&.kill
|
|
239
|
+
|
|
240
|
+
begin
|
|
241
|
+
@telemetry_reporter&.stop
|
|
242
|
+
rescue StandardError => e
|
|
243
|
+
LOG.debug "Error stopping telemetry reporter: #{e.message}"
|
|
244
|
+
end
|
|
245
|
+
@telemetry_reporter = nil
|
|
203
246
|
end
|
|
204
247
|
|
|
205
248
|
def fork
|
|
@@ -212,6 +255,93 @@ module Quonfig
|
|
|
212
255
|
|
|
213
256
|
private
|
|
214
257
|
|
|
258
|
+
# Construct and start the telemetry reporter if the options permit it.
|
|
259
|
+
# The reporter runs on a background thread and periodically POSTs
|
|
260
|
+
# context-shape and example-context batches to +telemetry_destination+.
|
|
261
|
+
def initialize_telemetry
|
|
262
|
+
shape_aggregator = nil
|
|
263
|
+
example_aggregator = nil
|
|
264
|
+
summaries_aggregator = nil
|
|
265
|
+
|
|
266
|
+
if @options.collect_max_shapes.to_i > 0
|
|
267
|
+
shape_aggregator = Quonfig::Telemetry::ContextShapeAggregator.new(
|
|
268
|
+
max_shapes: @options.collect_max_shapes
|
|
269
|
+
)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
if @options.collect_max_example_contexts.to_i > 0
|
|
273
|
+
example_aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(
|
|
274
|
+
max_contexts: @options.collect_max_example_contexts
|
|
275
|
+
)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
if @options.collect_max_evaluation_summaries.to_i > 0
|
|
279
|
+
summaries_aggregator = Quonfig::Telemetry::EvaluationSummariesAggregator.new(
|
|
280
|
+
max_keys: @options.collect_max_evaluation_summaries
|
|
281
|
+
)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
return if shape_aggregator.nil? && example_aggregator.nil? && summaries_aggregator.nil?
|
|
285
|
+
|
|
286
|
+
@telemetry_reporter = Quonfig::Telemetry::TelemetryReporter.new(
|
|
287
|
+
options: @options,
|
|
288
|
+
instance_hash: @instance_hash,
|
|
289
|
+
context_shape_aggregator: shape_aggregator,
|
|
290
|
+
example_contexts_aggregator: example_aggregator,
|
|
291
|
+
evaluation_summaries_aggregator: summaries_aggregator,
|
|
292
|
+
sync_interval: @options.collect_sync_interval
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
return unless @telemetry_reporter.enabled?
|
|
296
|
+
|
|
297
|
+
@telemetry_reporter.start
|
|
298
|
+
rescue StandardError => e
|
|
299
|
+
LOG.warn "[quonfig] Telemetry init failed: #{e.class}: #{e.message}"
|
|
300
|
+
@telemetry_reporter = nil
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Feed a matched EvalResult into the evaluation_summaries aggregator.
|
|
304
|
+
# A no-op when telemetry is disabled or eval-summaries collection is off.
|
|
305
|
+
def record_evaluation_for_telemetry(result)
|
|
306
|
+
return if @telemetry_reporter.nil?
|
|
307
|
+
return if result.nil?
|
|
308
|
+
|
|
309
|
+
config = result.config
|
|
310
|
+
return if config.nil?
|
|
311
|
+
|
|
312
|
+
@telemetry_reporter.record_evaluation(
|
|
313
|
+
config_id: config_field(config, :id),
|
|
314
|
+
config_key: config_field(config, :key),
|
|
315
|
+
config_type: config_field(config, :type),
|
|
316
|
+
conditional_value_index: result.rule_index,
|
|
317
|
+
weighted_value_index: nil,
|
|
318
|
+
selected_value: result.unwrapped_value,
|
|
319
|
+
reason: result.wire_reason
|
|
320
|
+
)
|
|
321
|
+
rescue StandardError => e
|
|
322
|
+
LOG.debug "[quonfig] Telemetry record_evaluation error: #{e.class}: #{e.message}"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def config_field(config, key)
|
|
326
|
+
return nil if config.nil?
|
|
327
|
+
|
|
328
|
+
config[key.to_s] || config[key.to_sym]
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Feed every evaluated context into the telemetry aggregators. A no-op
|
|
332
|
+
# when telemetry is disabled or no aggregators are active.
|
|
333
|
+
def record_context_for_telemetry(context)
|
|
334
|
+
return if @telemetry_reporter.nil?
|
|
335
|
+
return if context.nil?
|
|
336
|
+
|
|
337
|
+
context_obj = context.is_a?(Quonfig::Context) ? context : Quonfig::Context.new(context)
|
|
338
|
+
return if context_obj.blank?
|
|
339
|
+
|
|
340
|
+
@telemetry_reporter.record(context_obj)
|
|
341
|
+
rescue StandardError => e
|
|
342
|
+
LOG.debug "[quonfig] Telemetry record error: #{e.class}: #{e.message}"
|
|
343
|
+
end
|
|
344
|
+
|
|
215
345
|
def load_datadir_into_store
|
|
216
346
|
envelope = Quonfig::Datadir.load_envelope(@options.datadir, @options.environment)
|
|
217
347
|
envelope.configs.each { |cfg| @store.set(cfg['key'], cfg) }
|
data/lib/quonfig/context.rb
CHANGED
|
@@ -85,8 +85,17 @@ module Quonfig
|
|
|
85
85
|
@contexts[name.to_s] || NamedContext.new(name, {})
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
+
# Concatenate each named context's `key` (or `trackingId`) value into
|
|
89
|
+
# a stable identifier used for example-contexts dedupe. Mirrors
|
|
90
|
+
# sdk-node's groupedKey: contexts that don't have a `key` property
|
|
91
|
+
# contribute nothing — the resulting string is empty for "anonymous"
|
|
92
|
+
# contexts so the example aggregator can drop them entirely.
|
|
88
93
|
def grouped_key
|
|
89
|
-
@contexts.map
|
|
94
|
+
@contexts.values.map do |ctx|
|
|
95
|
+
h = ctx.to_h
|
|
96
|
+
v = h['key'] || h[:key] || h['trackingId'] || h[:trackingId]
|
|
97
|
+
v.nil? ? nil : v.to_s
|
|
98
|
+
end.compact.reject(&:empty?).sort.join('|')
|
|
90
99
|
end
|
|
91
100
|
|
|
92
101
|
include Comparable
|
data/lib/quonfig/datadir.rb
CHANGED
|
@@ -59,8 +59,7 @@ module Quonfig
|
|
|
59
59
|
environment ||= ENV['QUONFIG_ENVIRONMENT']
|
|
60
60
|
|
|
61
61
|
if environment.nil? || environment.empty?
|
|
62
|
-
raise
|
|
63
|
-
'[quonfig] Environment required for datadir mode; set the `environment` option or QUONFIG_ENVIRONMENT env var'
|
|
62
|
+
raise Quonfig::Errors::MissingEnvironmentError
|
|
64
63
|
end
|
|
65
64
|
|
|
66
65
|
unless File.exist?(quonfig_path)
|
|
@@ -70,8 +69,7 @@ module Quonfig
|
|
|
70
69
|
environments = JSON.parse(File.read(quonfig_path)).fetch('environments', [])
|
|
71
70
|
|
|
72
71
|
if !environments.empty? && !environments.include?(environment)
|
|
73
|
-
raise
|
|
74
|
-
"[quonfig] Environment \"#{environment}\" not found in workspace; available environments: #{environments.join(', ')}"
|
|
72
|
+
raise Quonfig::Errors::InvalidEnvironmentError.new(environment, environments)
|
|
75
73
|
end
|
|
76
74
|
|
|
77
75
|
environment
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
module Errors
|
|
5
|
+
# Raised when a confidential config's ciphertext cannot be decrypted —
|
|
6
|
+
# either the configured `decryptWith` key is missing/empty, or the
|
|
7
|
+
# AES-GCM payload itself is malformed/tampered.
|
|
8
|
+
#
|
|
9
|
+
# Mirrors sdk-python's QuonfigDecryptionError. Sdk-node currently
|
|
10
|
+
# raises plain `Error` for the same path; this class is the Ruby
|
|
11
|
+
# equivalent of the dedicated exception type.
|
|
12
|
+
class DecryptionError < Quonfig::Error
|
|
13
|
+
def initialize(key, cause = nil)
|
|
14
|
+
message = "Decryption failed for config '#{key}'"
|
|
15
|
+
message += ": #{cause}" if cause && !cause.to_s.empty?
|
|
16
|
+
super(message)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -4,7 +4,14 @@ module Quonfig
|
|
|
4
4
|
module Errors
|
|
5
5
|
class EnvVarParseError < Quonfig::Error
|
|
6
6
|
def initialize(env_var, config, env_var_name)
|
|
7
|
-
|
|
7
|
+
key, value_type =
|
|
8
|
+
if config.is_a?(Hash)
|
|
9
|
+
[config[:key] || config['key'],
|
|
10
|
+
config[:value_type] || config['value_type'] || config['valueType']]
|
|
11
|
+
else
|
|
12
|
+
[config.key, config.value_type]
|
|
13
|
+
end
|
|
14
|
+
super("Evaluating #{key} couldn't coerce #{env_var_name} of #{env_var} to #{value_type}")
|
|
8
15
|
end
|
|
9
16
|
end
|
|
10
17
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
module Errors
|
|
5
|
+
# Raised when the requested environment (via `environment:` option or
|
|
6
|
+
# QUONFIG_ENVIRONMENT) isn't listed in the workspace's `quonfig.json`.
|
|
7
|
+
# Catches typos like `"prdoduction"` early instead of silently
|
|
8
|
+
# evaluating against default rules.
|
|
9
|
+
class InvalidEnvironmentError < Quonfig::Error
|
|
10
|
+
def initialize(environment, available = nil)
|
|
11
|
+
message = "[quonfig] Environment \"#{environment}\" not found in workspace"
|
|
12
|
+
if available && !Array(available).empty?
|
|
13
|
+
message += "; available environments: #{Array(available).join(', ')}"
|
|
14
|
+
end
|
|
15
|
+
super(message)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
module Errors
|
|
5
|
+
# Raised when datadir mode is engaged but no environment was supplied
|
|
6
|
+
# (neither the `environment:` option nor the QUONFIG_ENVIRONMENT env
|
|
7
|
+
# var is set). Datadir mode requires an explicit environment; without
|
|
8
|
+
# one the loader cannot pick the right environment row from each
|
|
9
|
+
# config's `environments` array.
|
|
10
|
+
class MissingEnvironmentError < Quonfig::Error
|
|
11
|
+
def initialize(message = nil)
|
|
12
|
+
message ||= '[quonfig] Environment required for datadir mode; ' \
|
|
13
|
+
'set the `environment` option or QUONFIG_ENVIRONMENT env var'
|
|
14
|
+
super(message)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/quonfig/evaluator.rb
CHANGED
|
@@ -409,14 +409,54 @@ module Quonfig
|
|
|
409
409
|
# raw JSON Value hash (#value) and a coerced Ruby value (#unwrapped_value).
|
|
410
410
|
# The test suite and integration helpers consume both shapes.
|
|
411
411
|
class EvalResult
|
|
412
|
+
# Integer reason codes for the api-telemetry EvalSummaries wire
|
|
413
|
+
# format. Match sdk-node/src/reason.ts.
|
|
414
|
+
REASON_UNKNOWN = 0
|
|
415
|
+
REASON_STATIC = 1
|
|
416
|
+
REASON_TARGETING_MATCH = 2
|
|
417
|
+
REASON_SPLIT = 3
|
|
418
|
+
|
|
412
419
|
attr_reader :value, :rule_index, :config
|
|
420
|
+
attr_accessor :weighted_value_index
|
|
413
421
|
|
|
414
|
-
def initialize(value:, rule_index:, config:)
|
|
422
|
+
def initialize(value:, rule_index:, config:, weighted_value_index: nil)
|
|
415
423
|
@value = value
|
|
416
424
|
@rule_index = rule_index
|
|
417
425
|
@config = config
|
|
426
|
+
@weighted_value_index = weighted_value_index
|
|
418
427
|
end
|
|
419
428
|
|
|
429
|
+
# Integer reason code for telemetry. Mirrors sdk-node's computeReason:
|
|
430
|
+
# SPLIT when a weighted variant is picked, STATIC when the first rule
|
|
431
|
+
# of a config with no targeting rules matched, otherwise TARGETING_MATCH.
|
|
432
|
+
def wire_reason
|
|
433
|
+
return REASON_SPLIT unless @weighted_value_index.nil?
|
|
434
|
+
return REASON_STATIC if @rule_index == 0 && !EvalResult.send(:targeting_rules?, @config)
|
|
435
|
+
|
|
436
|
+
REASON_TARGETING_MATCH
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# True if any rule on the config (default or environment) has a
|
|
440
|
+
# non-ALWAYS_TRUE criterion. Used to decide STATIC vs TARGETING_MATCH.
|
|
441
|
+
def self.targeting_rules?(config)
|
|
442
|
+
return false if config.nil?
|
|
443
|
+
|
|
444
|
+
rules = []
|
|
445
|
+
%i[default environment].each do |section_key|
|
|
446
|
+
section = config[section_key.to_s] || config[section_key]
|
|
447
|
+
next if section.nil?
|
|
448
|
+
|
|
449
|
+
section_rules = section['rules'] || section[:rules] || []
|
|
450
|
+
rules.concat(section_rules)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
rules.any? do |rule|
|
|
454
|
+
criteria = rule['criteria'] || rule[:criteria] || []
|
|
455
|
+
criteria.any? { |c| (c['operator'] || c[:operator]) != 'ALWAYS_TRUE' }
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
private_class_method :targeting_rules?
|
|
459
|
+
|
|
420
460
|
# Raw underlying value without type coercion.
|
|
421
461
|
def raw_value
|
|
422
462
|
return nil if @value.nil?
|
|
@@ -446,7 +486,7 @@ module Quonfig
|
|
|
446
486
|
when 'string' then raw.to_s
|
|
447
487
|
when 'string_list' then raw.is_a?(Array) ? raw.map(&:to_s) : []
|
|
448
488
|
when 'log_level' then raw.is_a?(Numeric) ? raw : raw.to_s
|
|
449
|
-
when 'duration' then raw
|
|
489
|
+
when 'duration' then duration_to_millis(raw)
|
|
450
490
|
when 'json'
|
|
451
491
|
# JSON values must be native JS/Ruby types on the wire.
|
|
452
492
|
raw
|
|
@@ -460,5 +500,27 @@ module Quonfig
|
|
|
460
500
|
def value_type
|
|
461
501
|
type
|
|
462
502
|
end
|
|
503
|
+
|
|
504
|
+
private
|
|
505
|
+
|
|
506
|
+
# Coerce an ISO 8601 duration value (e.g. "PT0.2S", "P1DT6H2M1.5S")
|
|
507
|
+
# to integer milliseconds. The wire format may either be a bare ISO
|
|
508
|
+
# string in `value` or the structured `{ seconds, nanos }` proto-style
|
|
509
|
+
# shape. Mirrors sdk-node Resolver#unwrapValue duration branch.
|
|
510
|
+
def duration_to_millis(raw)
|
|
511
|
+
case raw
|
|
512
|
+
when Numeric
|
|
513
|
+
raw.to_i
|
|
514
|
+
when String
|
|
515
|
+
seconds = Quonfig::Duration.parse(raw)
|
|
516
|
+
(seconds * 1000).round
|
|
517
|
+
when Hash
|
|
518
|
+
secs = (raw['seconds'] || raw[:seconds] || 0).to_f
|
|
519
|
+
nanos = (raw['nanos'] || raw[:nanos] || 0).to_f
|
|
520
|
+
(secs * 1000 + nanos / 1_000_000.0).round
|
|
521
|
+
else
|
|
522
|
+
raw
|
|
523
|
+
end
|
|
524
|
+
end
|
|
463
525
|
end
|
|
464
526
|
end
|