quonfig 0.0.6 → 0.0.9
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 +29 -0
- data/VERSION +1 -1
- data/lib/quonfig/bound_client.rb +26 -0
- data/lib/quonfig/client.rb +212 -3
- data/lib/quonfig/context.rb +10 -1
- data/lib/quonfig/datadir.rb +2 -4
- data/lib/quonfig/dev_context.rb +41 -0
- 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 +84 -3
- data/lib/quonfig/http_connection.rb +1 -1
- data/lib/quonfig/options.rb +4 -1
- data/lib/quonfig/resolver.rb +215 -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 +212 -0
- data/lib/quonfig.rb +10 -0
- data/quonfig.gemspec +23 -4
- data/test/integration/test_context_precedence.rb +35 -117
- data/test/integration/test_datadir_environment.rb +15 -37
- data/test/integration/test_dev_overrides.rb +40 -0
- 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 +532 -4
- data/test/integration/test_post.rb +15 -5
- data/test/integration/test_telemetry.rb +77 -21
- data/test/test_client_telemetry.rb +175 -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_dev_context.rb +163 -0
- 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 +22 -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: e2dfc7385d529233f3b0b7c5fc13dbe7d0d648851ea0815bde792465a1774319
|
|
4
|
+
data.tar.gz: f6c702ba4eccdebeda95b8f9341fc0bb077a2c141c9397cb7f3dccb1cbfa782a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 368273026e247c7c01df6e85f11865f94bb588d4c4e512bbc44de65dda0a7659b96a8bd321de6ea676934b9483b0dd99d0fe516d3b288cf93a709f401bbfd824
|
|
7
|
+
data.tar.gz: be80686fa0c8748c4e1c3f802cfc51c605b1a3e31ec7f9682885a139b5081a79d91d9155833143671bfffa75728bf1091116456defd90bd610d17a5133287e03
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
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
|
+
|
|
3
32
|
## 0.0.6 - 2026-04-22
|
|
4
33
|
|
|
5
34
|
- **New: `Quonfig::StdlibFormatter` + `client.stdlib_formatter(logger_name:)`** —
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.0.
|
|
1
|
+
0.0.9
|
data/lib/quonfig/bound_client.rb
CHANGED
|
@@ -40,6 +40,32 @@ module Quonfig
|
|
|
40
40
|
@client.get_json(key, default: default, context: @context)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# ---- Details getters ----------------------------------------------
|
|
44
|
+
|
|
45
|
+
def get_bool_details(key)
|
|
46
|
+
@client.get_bool_details(key, context: @context)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def get_string_details(key)
|
|
50
|
+
@client.get_string_details(key, context: @context)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def get_int_details(key)
|
|
54
|
+
@client.get_int_details(key, context: @context)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def get_float_details(key)
|
|
58
|
+
@client.get_float_details(key, context: @context)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def get_string_list_details(key)
|
|
62
|
+
@client.get_string_list_details(key, context: @context)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def get_json_details(key)
|
|
66
|
+
@client.get_json_details(key, context: @context)
|
|
67
|
+
end
|
|
68
|
+
|
|
43
69
|
def enabled?(feature_name)
|
|
44
70
|
@client.enabled?(feature_name, @context)
|
|
45
71
|
end
|
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 =
|
|
@@ -32,7 +32,7 @@ module Quonfig
|
|
|
32
32
|
else
|
|
33
33
|
Quonfig::Options.new(option_kwargs)
|
|
34
34
|
end
|
|
35
|
-
@global_context =
|
|
35
|
+
@global_context = build_initial_global_context(@options)
|
|
36
36
|
@instance_hash = SecureRandom.uuid
|
|
37
37
|
@store = store || Quonfig::ConfigStore.new
|
|
38
38
|
@evaluator = Quonfig::Evaluator.new(@store, env_id: @options.environment)
|
|
@@ -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
|
|
|
@@ -90,6 +103,38 @@ module Quonfig
|
|
|
90
103
|
typed_get(key, :json, default: default, context: context)
|
|
91
104
|
end
|
|
92
105
|
|
|
106
|
+
# ---- Details getters ----------------------------------------------
|
|
107
|
+
#
|
|
108
|
+
# Mirrors the typed getters above but returns a +Quonfig::EvaluationDetails+
|
|
109
|
+
# carrying the OpenFeature-aligned resolution +reason+ ("STATIC",
|
|
110
|
+
# "TARGETING_MATCH", "SPLIT", "DEFAULT", or "ERROR") plus an
|
|
111
|
+
# +error_code+/+error_message+ on the error path. These methods never
|
|
112
|
+
# raise — exceptions are caught and rendered as ERROR details.
|
|
113
|
+
|
|
114
|
+
def get_bool_details(key, context: NO_DEFAULT_PROVIDED)
|
|
115
|
+
evaluate_details(key, :bool, context)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def get_string_details(key, context: NO_DEFAULT_PROVIDED)
|
|
119
|
+
evaluate_details(key, String, context)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def get_int_details(key, context: NO_DEFAULT_PROVIDED)
|
|
123
|
+
evaluate_details(key, Integer, context)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def get_float_details(key, context: NO_DEFAULT_PROVIDED)
|
|
127
|
+
evaluate_details(key, Float, context)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def get_string_list_details(key, context: NO_DEFAULT_PROVIDED)
|
|
131
|
+
evaluate_details(key, :string_list, context)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def get_json_details(key, context: NO_DEFAULT_PROVIDED)
|
|
135
|
+
evaluate_details(key, :json, context)
|
|
136
|
+
end
|
|
137
|
+
|
|
93
138
|
def enabled?(feature_name, jit_context = NO_DEFAULT_PROVIDED)
|
|
94
139
|
value = get(feature_name, false, jit_context)
|
|
95
140
|
value == true || value == 'true'
|
|
@@ -223,6 +268,13 @@ module Quonfig
|
|
|
223
268
|
thread = @poll_thread
|
|
224
269
|
@poll_thread = nil
|
|
225
270
|
thread&.kill
|
|
271
|
+
|
|
272
|
+
begin
|
|
273
|
+
@telemetry_reporter&.stop
|
|
274
|
+
rescue StandardError => e
|
|
275
|
+
LOG.debug "Error stopping telemetry reporter: #{e.message}"
|
|
276
|
+
end
|
|
277
|
+
@telemetry_reporter = nil
|
|
226
278
|
end
|
|
227
279
|
|
|
228
280
|
def fork
|
|
@@ -235,6 +287,93 @@ module Quonfig
|
|
|
235
287
|
|
|
236
288
|
private
|
|
237
289
|
|
|
290
|
+
# Construct and start the telemetry reporter if the options permit it.
|
|
291
|
+
# The reporter runs on a background thread and periodically POSTs
|
|
292
|
+
# context-shape and example-context batches to +telemetry_destination+.
|
|
293
|
+
def initialize_telemetry
|
|
294
|
+
shape_aggregator = nil
|
|
295
|
+
example_aggregator = nil
|
|
296
|
+
summaries_aggregator = nil
|
|
297
|
+
|
|
298
|
+
if @options.collect_max_shapes.to_i > 0
|
|
299
|
+
shape_aggregator = Quonfig::Telemetry::ContextShapeAggregator.new(
|
|
300
|
+
max_shapes: @options.collect_max_shapes
|
|
301
|
+
)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
if @options.collect_max_example_contexts.to_i > 0
|
|
305
|
+
example_aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(
|
|
306
|
+
max_contexts: @options.collect_max_example_contexts
|
|
307
|
+
)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
if @options.collect_max_evaluation_summaries.to_i > 0
|
|
311
|
+
summaries_aggregator = Quonfig::Telemetry::EvaluationSummariesAggregator.new(
|
|
312
|
+
max_keys: @options.collect_max_evaluation_summaries
|
|
313
|
+
)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
return if shape_aggregator.nil? && example_aggregator.nil? && summaries_aggregator.nil?
|
|
317
|
+
|
|
318
|
+
@telemetry_reporter = Quonfig::Telemetry::TelemetryReporter.new(
|
|
319
|
+
options: @options,
|
|
320
|
+
instance_hash: @instance_hash,
|
|
321
|
+
context_shape_aggregator: shape_aggregator,
|
|
322
|
+
example_contexts_aggregator: example_aggregator,
|
|
323
|
+
evaluation_summaries_aggregator: summaries_aggregator,
|
|
324
|
+
sync_interval: @options.collect_sync_interval
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
return unless @telemetry_reporter.enabled?
|
|
328
|
+
|
|
329
|
+
@telemetry_reporter.start
|
|
330
|
+
rescue StandardError => e
|
|
331
|
+
LOG.warn "[quonfig] Telemetry init failed: #{e.class}: #{e.message}"
|
|
332
|
+
@telemetry_reporter = nil
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Feed a matched EvalResult into the evaluation_summaries aggregator.
|
|
336
|
+
# A no-op when telemetry is disabled or eval-summaries collection is off.
|
|
337
|
+
def record_evaluation_for_telemetry(result)
|
|
338
|
+
return if @telemetry_reporter.nil?
|
|
339
|
+
return if result.nil?
|
|
340
|
+
|
|
341
|
+
config = result.config
|
|
342
|
+
return if config.nil?
|
|
343
|
+
|
|
344
|
+
@telemetry_reporter.record_evaluation(
|
|
345
|
+
config_id: config_field(config, :id),
|
|
346
|
+
config_key: config_field(config, :key),
|
|
347
|
+
config_type: config_field(config, :type),
|
|
348
|
+
conditional_value_index: result.rule_index,
|
|
349
|
+
weighted_value_index: result.weighted_value_index,
|
|
350
|
+
selected_value: result.unwrapped_value,
|
|
351
|
+
reason: result.wire_reason
|
|
352
|
+
)
|
|
353
|
+
rescue StandardError => e
|
|
354
|
+
LOG.debug "[quonfig] Telemetry record_evaluation error: #{e.class}: #{e.message}"
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def config_field(config, key)
|
|
358
|
+
return nil if config.nil?
|
|
359
|
+
|
|
360
|
+
config[key.to_s] || config[key.to_sym]
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Feed every evaluated context into the telemetry aggregators. A no-op
|
|
364
|
+
# when telemetry is disabled or no aggregators are active.
|
|
365
|
+
def record_context_for_telemetry(context)
|
|
366
|
+
return if @telemetry_reporter.nil?
|
|
367
|
+
return if context.nil?
|
|
368
|
+
|
|
369
|
+
context_obj = context.is_a?(Quonfig::Context) ? context : Quonfig::Context.new(context)
|
|
370
|
+
return if context_obj.blank?
|
|
371
|
+
|
|
372
|
+
@telemetry_reporter.record(context_obj)
|
|
373
|
+
rescue StandardError => e
|
|
374
|
+
LOG.debug "[quonfig] Telemetry record error: #{e.class}: #{e.message}"
|
|
375
|
+
end
|
|
376
|
+
|
|
238
377
|
def load_datadir_into_store
|
|
239
378
|
envelope = Quonfig::Datadir.load_envelope(@options.datadir, @options.environment)
|
|
240
379
|
envelope.configs.each { |cfg| @store.set(cfg['key'], cfg) }
|
|
@@ -334,6 +473,21 @@ module Quonfig
|
|
|
334
473
|
merge_contexts(@global_context, jit)
|
|
335
474
|
end
|
|
336
475
|
|
|
476
|
+
# Combine the customer-supplied globalContext with the optional dev
|
|
477
|
+
# context loaded from ~/.quonfig/tokens.json. Dev context goes UNDER the
|
|
478
|
+
# customer's so any explicit `quonfig-user` keys win on collision.
|
|
479
|
+
def build_initial_global_context(options)
|
|
480
|
+
customer = normalize_context(options.global_context)
|
|
481
|
+
enabled = options.enable_quonfig_user_context == true ||
|
|
482
|
+
ENV['QUONFIG_DEV_CONTEXT'] == 'true'
|
|
483
|
+
return customer unless enabled
|
|
484
|
+
|
|
485
|
+
dev = Quonfig::DevContext.load_quonfig_user_context
|
|
486
|
+
return customer if dev.nil?
|
|
487
|
+
|
|
488
|
+
merge_contexts(dev, customer)
|
|
489
|
+
end
|
|
490
|
+
|
|
337
491
|
def normalize_context(ctx)
|
|
338
492
|
return {} if ctx.nil?
|
|
339
493
|
return ctx if ctx.is_a?(Hash)
|
|
@@ -378,6 +532,61 @@ module Quonfig
|
|
|
378
532
|
nil
|
|
379
533
|
end
|
|
380
534
|
|
|
535
|
+
# Build a Quonfig::EvaluationDetails for +key+, evaluated against the
|
|
536
|
+
# caller's context, after coercing/checking +expected_type+. Never
|
|
537
|
+
# raises; all exceptions become ERROR details.
|
|
538
|
+
def evaluate_details(key, expected_type, context)
|
|
539
|
+
jit = context == NO_DEFAULT_PROVIDED ? nil : context
|
|
540
|
+
ctx = build_context(jit)
|
|
541
|
+
record_context_for_telemetry(ctx)
|
|
542
|
+
|
|
543
|
+
result =
|
|
544
|
+
begin
|
|
545
|
+
@resolver.get(key, ctx)
|
|
546
|
+
rescue Quonfig::Errors::MissingDefaultError => e
|
|
547
|
+
return Quonfig::EvaluationDetails.new(
|
|
548
|
+
value: nil,
|
|
549
|
+
reason: Quonfig::EvaluationDetails::REASON_ERROR,
|
|
550
|
+
error_code: Quonfig::EvaluationDetails::ERROR_FLAG_NOT_FOUND,
|
|
551
|
+
error_message: e.message
|
|
552
|
+
)
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
if result.nil?
|
|
556
|
+
return Quonfig::EvaluationDetails.new(
|
|
557
|
+
value: nil,
|
|
558
|
+
reason: Quonfig::EvaluationDetails::REASON_DEFAULT
|
|
559
|
+
)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
record_evaluation_for_telemetry(result)
|
|
563
|
+
|
|
564
|
+
raw_value = result.unwrapped_value
|
|
565
|
+
|
|
566
|
+
begin
|
|
567
|
+
coerced = coerce_and_check(key, raw_value, expected_type) unless raw_value.nil?
|
|
568
|
+
rescue Quonfig::Errors::TypeMismatchError => e
|
|
569
|
+
return Quonfig::EvaluationDetails.new(
|
|
570
|
+
value: nil,
|
|
571
|
+
reason: Quonfig::EvaluationDetails::REASON_ERROR,
|
|
572
|
+
error_code: Quonfig::EvaluationDetails::ERROR_TYPE_MISMATCH,
|
|
573
|
+
error_message: e.message
|
|
574
|
+
)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
Quonfig::EvaluationDetails.new(
|
|
578
|
+
value: coerced,
|
|
579
|
+
reason: result.of_reason
|
|
580
|
+
)
|
|
581
|
+
rescue StandardError => e
|
|
582
|
+
Quonfig::EvaluationDetails.new(
|
|
583
|
+
value: nil,
|
|
584
|
+
reason: Quonfig::EvaluationDetails::REASON_ERROR,
|
|
585
|
+
error_code: Quonfig::EvaluationDetails::ERROR_GENERAL,
|
|
586
|
+
error_message: e.message
|
|
587
|
+
)
|
|
588
|
+
end
|
|
589
|
+
|
|
381
590
|
def typed_get(key, expected_type, default:, context:)
|
|
382
591
|
jit = context == NO_DEFAULT_PROVIDED ? NO_DEFAULT_PROVIDED : context
|
|
383
592
|
value = get(key, default, jit)
|
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,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Quonfig
|
|
6
|
+
# Dev-only context loader. Reads ~/.quonfig/tokens.json (written by
|
|
7
|
+
# `qfg login`) and returns {'quonfig-user' => {'email' => ...}} when a
|
|
8
|
+
# userEmail is present. Returns nil when the file is missing, unreadable,
|
|
9
|
+
# or has no userEmail.
|
|
10
|
+
#
|
|
11
|
+
# The attribute is dev-only by construction: production servers do not
|
|
12
|
+
# run `qfg login` and therefore have no tokens file. Rules keyed on
|
|
13
|
+
# `quonfig-user.email` are dead code in prod.
|
|
14
|
+
module DevContext
|
|
15
|
+
TOKENS_BASENAME = File.join('.quonfig', 'tokens.json')
|
|
16
|
+
|
|
17
|
+
def self.load_quonfig_user_context
|
|
18
|
+
path = File.join(Dir.home, TOKENS_BASENAME)
|
|
19
|
+
return nil unless File.exist?(path)
|
|
20
|
+
|
|
21
|
+
raw = begin
|
|
22
|
+
File.read(path)
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
warn "[quonfig] dev-context: could not read #{path} (#{e.class}: #{e.message}); skipping injection"
|
|
25
|
+
return nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
parsed = begin
|
|
29
|
+
JSON.parse(raw)
|
|
30
|
+
rescue JSON::ParserError => e
|
|
31
|
+
warn "[quonfig] dev-context: could not parse #{path} (#{e.message}); skipping injection"
|
|
32
|
+
return nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
email = parsed.is_a?(Hash) ? parsed['userEmail'] : nil
|
|
36
|
+
return nil unless email.is_a?(String) && !email.empty?
|
|
37
|
+
|
|
38
|
+
{ 'quonfig-user' => { 'email' => email } }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -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,73 @@ 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
|
-
|
|
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
|
|
413
418
|
|
|
414
|
-
|
|
419
|
+
attr_reader :value, :rule_index, :config, :reportable_value
|
|
420
|
+
attr_accessor :weighted_value_index
|
|
421
|
+
|
|
422
|
+
def initialize(value:, rule_index:, config:, weighted_value_index: nil, reportable_value: nil)
|
|
415
423
|
@value = value
|
|
416
424
|
@rule_index = rule_index
|
|
417
425
|
@config = config
|
|
426
|
+
@weighted_value_index = weighted_value_index
|
|
427
|
+
# Telemetry-safe substitute for #unwrapped_value. Set by Resolver when
|
|
428
|
+
# the underlying Value was confidential / decryptWith, so callers
|
|
429
|
+
# (the eval-summary aggregator) never see the plaintext. Mirrors
|
|
430
|
+
# ReforgeHQ/sdk-ruby ConfigValueUnwrapper#reportable_value (the
|
|
431
|
+
# `*****<5-md5>` hash form).
|
|
432
|
+
@reportable_value = reportable_value
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Integer reason code for telemetry. Mirrors sdk-node's computeReason:
|
|
436
|
+
# SPLIT when a weighted variant is picked, STATIC when the first rule
|
|
437
|
+
# of a config with no targeting rules matched, otherwise TARGETING_MATCH.
|
|
438
|
+
def wire_reason
|
|
439
|
+
return REASON_SPLIT unless @weighted_value_index.nil?
|
|
440
|
+
return REASON_STATIC if @rule_index == 0 && !EvalResult.send(:targeting_rules?, @config)
|
|
441
|
+
|
|
442
|
+
REASON_TARGETING_MATCH
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# OpenFeature-aligned reason string. Same classification logic as
|
|
446
|
+
# +wire_reason+ but as a public string the OF provider (and any
|
|
447
|
+
# third-party consumer of EvaluationDetails) can pass straight through.
|
|
448
|
+
#
|
|
449
|
+
# Returns one of: "STATIC", "TARGETING_MATCH", "SPLIT".
|
|
450
|
+
def of_reason
|
|
451
|
+
case wire_reason
|
|
452
|
+
when REASON_STATIC then EvaluationDetails::REASON_STATIC
|
|
453
|
+
when REASON_SPLIT then EvaluationDetails::REASON_SPLIT
|
|
454
|
+
else EvaluationDetails::REASON_TARGETING_MATCH
|
|
455
|
+
end
|
|
418
456
|
end
|
|
419
457
|
|
|
458
|
+
# True if any rule on the config (default or environment) has a
|
|
459
|
+
# non-ALWAYS_TRUE criterion. Used to decide STATIC vs TARGETING_MATCH.
|
|
460
|
+
def self.targeting_rules?(config)
|
|
461
|
+
return false if config.nil?
|
|
462
|
+
|
|
463
|
+
rules = []
|
|
464
|
+
%i[default environment].each do |section_key|
|
|
465
|
+
section = config[section_key.to_s] || config[section_key]
|
|
466
|
+
next if section.nil?
|
|
467
|
+
|
|
468
|
+
section_rules = section['rules'] || section[:rules] || []
|
|
469
|
+
rules.concat(section_rules)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
rules.any? do |rule|
|
|
473
|
+
criteria = rule['criteria'] || rule[:criteria] || []
|
|
474
|
+
criteria.any? { |c| (c['operator'] || c[:operator]) != 'ALWAYS_TRUE' }
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
private_class_method :targeting_rules?
|
|
478
|
+
|
|
420
479
|
# Raw underlying value without type coercion.
|
|
421
480
|
def raw_value
|
|
422
481
|
return nil if @value.nil?
|
|
@@ -446,7 +505,7 @@ module Quonfig
|
|
|
446
505
|
when 'string' then raw.to_s
|
|
447
506
|
when 'string_list' then raw.is_a?(Array) ? raw.map(&:to_s) : []
|
|
448
507
|
when 'log_level' then raw.is_a?(Numeric) ? raw : raw.to_s
|
|
449
|
-
when 'duration' then raw
|
|
508
|
+
when 'duration' then duration_to_millis(raw)
|
|
450
509
|
when 'json'
|
|
451
510
|
# JSON values must be native JS/Ruby types on the wire.
|
|
452
511
|
raw
|
|
@@ -460,5 +519,27 @@ module Quonfig
|
|
|
460
519
|
def value_type
|
|
461
520
|
type
|
|
462
521
|
end
|
|
522
|
+
|
|
523
|
+
private
|
|
524
|
+
|
|
525
|
+
# Coerce an ISO 8601 duration value (e.g. "PT0.2S", "P1DT6H2M1.5S")
|
|
526
|
+
# to integer milliseconds. The wire format may either be a bare ISO
|
|
527
|
+
# string in `value` or the structured `{ seconds, nanos }` proto-style
|
|
528
|
+
# shape. Mirrors sdk-node Resolver#unwrapValue duration branch.
|
|
529
|
+
def duration_to_millis(raw)
|
|
530
|
+
case raw
|
|
531
|
+
when Numeric
|
|
532
|
+
raw.to_i
|
|
533
|
+
when String
|
|
534
|
+
seconds = Quonfig::Duration.parse(raw)
|
|
535
|
+
(seconds * 1000).round
|
|
536
|
+
when Hash
|
|
537
|
+
secs = (raw['seconds'] || raw[:seconds] || 0).to_f
|
|
538
|
+
nanos = (raw['nanos'] || raw[:nanos] || 0).to_f
|
|
539
|
+
(secs * 1000 + nanos / 1_000_000.0).round
|
|
540
|
+
else
|
|
541
|
+
raw
|
|
542
|
+
end
|
|
543
|
+
end
|
|
463
544
|
end
|
|
464
545
|
end
|
data/lib/quonfig/options.rb
CHANGED
|
@@ -21,6 +21,7 @@ module Quonfig
|
|
|
21
21
|
attr_reader :poll_interval
|
|
22
22
|
attr_reader :global_context
|
|
23
23
|
attr_reader :logger_key
|
|
24
|
+
attr_reader :enable_quonfig_user_context
|
|
24
25
|
attr_accessor :is_fork
|
|
25
26
|
|
|
26
27
|
module ON_INITIALIZATION_FAILURE
|
|
@@ -74,7 +75,8 @@ module Quonfig
|
|
|
74
75
|
collect_max_evaluation_summaries: DEFAULT_MAX_EVAL_SUMMARIES,
|
|
75
76
|
allow_telemetry_in_local_mode: false,
|
|
76
77
|
global_context: {},
|
|
77
|
-
logger_key: nil
|
|
78
|
+
logger_key: nil,
|
|
79
|
+
enable_quonfig_user_context: false
|
|
78
80
|
)
|
|
79
81
|
@sdk_key = sdk_key
|
|
80
82
|
@environment = environment
|
|
@@ -94,6 +96,7 @@ module Quonfig
|
|
|
94
96
|
@is_fork = false
|
|
95
97
|
@global_context = global_context
|
|
96
98
|
@logger_key = logger_key
|
|
99
|
+
@enable_quonfig_user_context = enable_quonfig_user_context
|
|
97
100
|
|
|
98
101
|
# defaults that may be overridden by context_upload_mode
|
|
99
102
|
@collect_shapes = false
|