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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/VERSION +1 -1
  4. data/lib/quonfig/bound_client.rb +26 -0
  5. data/lib/quonfig/client.rb +212 -3
  6. data/lib/quonfig/context.rb +10 -1
  7. data/lib/quonfig/datadir.rb +2 -4
  8. data/lib/quonfig/dev_context.rb +41 -0
  9. data/lib/quonfig/errors/decryption_error.rb +20 -0
  10. data/lib/quonfig/errors/env_var_parse_error.rb +8 -1
  11. data/lib/quonfig/errors/invalid_environment_error.rb +19 -0
  12. data/lib/quonfig/errors/missing_environment_error.rb +18 -0
  13. data/lib/quonfig/evaluator.rb +84 -3
  14. data/lib/quonfig/http_connection.rb +1 -1
  15. data/lib/quonfig/options.rb +4 -1
  16. data/lib/quonfig/resolver.rb +215 -2
  17. data/lib/quonfig/stdlib_formatter.rb +95 -0
  18. data/lib/quonfig/telemetry/context_shape.rb +33 -0
  19. data/lib/quonfig/telemetry/context_shape_aggregator.rb +82 -0
  20. data/lib/quonfig/telemetry/evaluation_summaries_aggregator.rb +119 -0
  21. data/lib/quonfig/telemetry/example_contexts_aggregator.rb +101 -0
  22. data/lib/quonfig/telemetry/telemetry_reporter.rb +212 -0
  23. data/lib/quonfig.rb +10 -0
  24. data/quonfig.gemspec +23 -4
  25. data/test/integration/test_context_precedence.rb +35 -117
  26. data/test/integration/test_datadir_environment.rb +15 -37
  27. data/test/integration/test_dev_overrides.rb +40 -0
  28. data/test/integration/test_enabled.rb +157 -463
  29. data/test/integration/test_enabled_with_contexts.rb +19 -49
  30. data/test/integration/test_get.rb +43 -131
  31. data/test/integration/test_get_feature_flag.rb +7 -13
  32. data/test/integration/test_get_or_raise.rb +19 -45
  33. data/test/integration/test_get_weighted_values.rb +9 -4
  34. data/test/integration/test_helpers.rb +532 -4
  35. data/test/integration/test_post.rb +15 -5
  36. data/test/integration/test_telemetry.rb +77 -21
  37. data/test/test_client_telemetry.rb +175 -0
  38. data/test/test_context.rb +4 -1
  39. data/test/test_context_shape.rb +37 -0
  40. data/test/test_context_shape_aggregator.rb +126 -0
  41. data/test/test_datadir.rb +6 -2
  42. data/test/test_dev_context.rb +163 -0
  43. data/test/test_evaluation_summaries_aggregator.rb +180 -0
  44. data/test/test_example_contexts_aggregator.rb +119 -0
  45. data/test/test_http_connection.rb +1 -1
  46. data/test/test_resolver.rb +149 -2
  47. data/test/test_should_log.rb +186 -0
  48. data/test/test_stdlib_formatter.rb +195 -0
  49. data/test/test_telemetry_reporter.rb +209 -0
  50. metadata +22 -3
  51. data/scripts/generate_integration_tests.rb +0 -362
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 875b035905394220e72fec4fc1afc3bc62ad6e2975b745098fa4482e1dc348d0
4
- data.tar.gz: bd957d0ec077f3a49daf719deb3ea4db64d1b23a69f6264c91385ad21ff06720
3
+ metadata.gz: e2dfc7385d529233f3b0b7c5fc13dbe7d0d648851ea0815bde792465a1774319
4
+ data.tar.gz: f6c702ba4eccdebeda95b8f9341fc0bb077a2c141c9397cb7f3dccb1cbfa782a
5
5
  SHA512:
6
- metadata.gz: 9a592d367c361e42e57c42b0ca0929927808e834b5f876545e17436403b11eb5abe08d2996317e5ec5360a1df2228b48a247e22304d6fcda809c3ffee0e3f772
7
- data.tar.gz: f3f9b9b1135ab775861164fb89701e6a3cf6b5cb23078d55de89604b13081ce71b7fe00bed192cd9d9134d64fd406fec954efa793e62a2fb558a8ca3cddbec4f
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.6
1
+ 0.0.9
@@ -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
@@ -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 = normalize_context(@options.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
- result = @resolver.get(key, ctx)
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)
@@ -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 { |_, ctx| ctx.key }.sort.join('|')
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
@@ -59,8 +59,7 @@ module Quonfig
59
59
  environment ||= ENV['QUONFIG_ENVIRONMENT']
60
60
 
61
61
  if environment.nil? || environment.empty?
62
- raise ArgumentError,
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 ArgumentError,
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
- super("Evaluating #{config.key} couldn't coerce #{env_var_name} of #{env_var} to #{config.value_type}")
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
@@ -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
- attr_reader :value, :rule_index, :config
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
- def initialize(value:, rule_index:, config:)
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.to_s
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
@@ -5,7 +5,7 @@ require 'json'
5
5
 
6
6
  module Quonfig
7
7
  class HttpConnection
8
- SDK_VERSION = 'ruby-0.1.0'
8
+ SDK_VERSION = "ruby-#{Quonfig::VERSION}".freeze
9
9
 
10
10
  JSON_HEADERS = {
11
11
  'Content-Type' => 'application/json',
@@ -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