quonfig 0.0.8 → 0.0.10

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 18707780a8ad33c299973f4de01bc0b76d10161902f977b9771c10c2d4a23fde
4
- data.tar.gz: 6980b904632659b97f16636e9a317bc8b3e8cbf02cc0b8f75ecf51325b8ba472
3
+ metadata.gz: 94866b65b3e3c4e834897981847da01f350984b2f7126f8259318ba385b8fd77
4
+ data.tar.gz: bc173a4300c1475f596921de2a2d24e02e5de648503318491a602c19123dd329
5
5
  SHA512:
6
- metadata.gz: 74c6a59b81fd222ddd522e52301ea74a1f9bc76b18ecbfe50c01a46e21539bdc5cea6a38a927938fb497f6003a64a2e796d177f2ed3ee527d5891f6cd9ca63a4
7
- data.tar.gz: 94b1c109e6db3df2ea115e62f80f099430752779faa9655ee6f53c20c614fa42ccdc8e350342985cb09861e286bd91b857381508849a81fada865927c98f7f22
6
+ metadata.gz: 875265c218e5a465abced6d47563ef7c86ade61ca2d98367059bc800dec55e1148badf58e60b632e468ab565ef5ec80e587b8f0fd045f5300b000f64fe12b909
7
+ data.tar.gz: 6725f7b13ac6ba43d0e7903963c5de4b1bbe03f1ae7e51399e4d0cc8877f6ee637ef2850e396e65c8d5c44cac2ee7cfa0b2f1724d390d43746d421d7e465c42a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.10 - 2026-05-01
4
+
5
+ - **BREAKING (env): `QUONFIG_TELEMETRY_URL` and `QUONFIG_API_URLS` env vars
6
+ removed.** Replaced by a single `QUONFIG_DOMAIN` env var (default
7
+ `quonfig.com`) that derives api, sse, and telemetry URLs uniformly. e.g.
8
+ `QUONFIG_DOMAIN=quonfig-staging.com` → `https://primary.quonfig-staging.com`,
9
+ `https://stream.primary.quonfig-staging.com`,
10
+ `https://telemetry.quonfig-staging.com`. Mirrors the CLI's
11
+ `domain-urls.ts` convention and matches sdk-go / sdk-node. Resolution order
12
+ (highest wins): explicit `api_urls:` / `telemetry_url:` kwargs >
13
+ `QUONFIG_DOMAIN` > hardcoded default. Fixes qfg-w6gg, where the prior
14
+ primary-prefix regex silently fell through to prod telemetry on staging
15
+ hosts. The new `Quonfig::Options#init` also accepts an explicit
16
+ `telemetry_url:` kwarg (was previously documented but not wired up).
17
+ - **Default `api_urls` now includes secondary.** Was `[primary]`, now
18
+ `[primary, secondary]` to match every other SDK and provide failover.
19
+
3
20
  ## 0.0.8 - 2026-04-26
4
21
 
5
22
  - **Fix (gemspec): drop deleted `scripts/` entry from manifest** — regenerated
data/CLAUDE.md CHANGED
@@ -26,4 +26,4 @@ integration suite cannot resolve its YAML specs.
26
26
  - `QUONFIG_BACKEND_SDK_KEY` — backend SDK key for authenticated config delivery
27
27
  - `QUONFIG_DIR` — path to a local Quonfig workspace (datadir mode)
28
28
  - `QUONFIG_ENVIRONMENT` — which environment to evaluate (`production`, `staging`, `development`)
29
- - `QUONFIG_TELEMETRY_URL` — telemetry ingest endpoint
29
+ - `QUONFIG_DOMAIN` — base domain used to derive api/sse/telemetry URLs (default `quonfig.com`). Setting `QUONFIG_DOMAIN=quonfig-staging.com` derives `https://primary.quonfig-staging.com`, `https://stream.primary.quonfig-staging.com`, and `https://telemetry.quonfig-staging.com` automatically. Explicit `api_urls:` / `telemetry_url:` kwargs override this.
data/README.md CHANGED
@@ -114,14 +114,14 @@ client = Quonfig::Client.new # reads QUONFIG_DIR + QUONFIG_ENVIRONMENT
114
114
  | `QUONFIG_BACKEND_SDK_KEY` | SDK key used to authenticate against the Quonfig API. Used when `sdk_key:` is omitted. |
115
115
  | `QUONFIG_DIR` | Path to a workspace directory. When set, the SDK runs in datadir/offline mode. |
116
116
  | `QUONFIG_ENVIRONMENT` | Environment name (`production`, `staging`, `development`) evaluated in datadir mode. |
117
- | `QUONFIG_TELEMETRY_URL` | Overrides the telemetry endpoint. Defaults to `https://telemetry.quonfig.com`. |
117
+ | `QUONFIG_DOMAIN` | Base domain used to derive api, sse, and telemetry URLs. Defaults to `quonfig.com`. Set to `quonfig-staging.com` to point at staging. Explicit `api_urls:` / `telemetry_url:` kwargs override this. |
118
118
 
119
119
  ## Constructor options
120
120
 
121
121
  ```ruby
122
122
  Quonfig::Client.new(
123
123
  sdk_key: '...', # required unless QUONFIG_BACKEND_SDK_KEY is set
124
- api_urls: ['https://primary.quonfig.com'],
124
+ api_urls: ['https://primary.quonfig.com', 'https://secondary.quonfig.com'],
125
125
  telemetry_url: 'https://telemetry.quonfig.com',
126
126
  enable_sse: true,
127
127
  enable_polling: false,
@@ -137,8 +137,8 @@ Quonfig::Client.new(
137
137
  | Option | Type | Default | Description |
138
138
  |-------------------|----------------------------|---------------------------------------------------------------------|---------------------------------------------------------------------------------------------------|
139
139
  | `sdk_key` | `String` | `ENV['QUONFIG_BACKEND_SDK_KEY']` | SDK key for API authentication. |
140
- | `api_urls` | `Array<String>` | `['https://primary.quonfig.com']` | Ordered list of API base URLs to try. SSE stream URLs are derived by prepending `stream.` to each hostname. |
141
- | `telemetry_url` | `String` | `https://telemetry.quonfig.com` (or `ENV['QUONFIG_TELEMETRY_URL']`) | Base URL for the telemetry service. |
140
+ | `api_urls` | `Array<String>` | `["https://primary.${QUONFIG_DOMAIN}", "https://secondary.${QUONFIG_DOMAIN}"]` | Ordered list of API base URLs to try. SSE stream URLs are derived by prepending `stream.` to each hostname. Defaults derive from `QUONFIG_DOMAIN` (default `quonfig.com`). |
141
+ | `telemetry_url` | `String` | `https://telemetry.${QUONFIG_DOMAIN}` | Base URL for the telemetry service. Default derives from `QUONFIG_DOMAIN`. |
142
142
  | `enable_sse` | `Boolean` | `true` | Receive real-time updates over Server-Sent Events. |
143
143
  | `enable_polling` | `Boolean` | `false` | Poll the API on an interval as a fallback. |
144
144
  | `poll_interval` | `Integer` (seconds) | `60` | Polling interval when `enable_polling` is `true`. |
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.0.8
1
+ 0.0.10
@@ -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
@@ -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)
@@ -103,6 +103,38 @@ module Quonfig
103
103
  typed_get(key, :json, default: default, context: context)
104
104
  end
105
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
+
106
138
  def enabled?(feature_name, jit_context = NO_DEFAULT_PROVIDED)
107
139
  value = get(feature_name, false, jit_context)
108
140
  value == true || value == 'true'
@@ -314,7 +346,7 @@ module Quonfig
314
346
  config_key: config_field(config, :key),
315
347
  config_type: config_field(config, :type),
316
348
  conditional_value_index: result.rule_index,
317
- weighted_value_index: nil,
349
+ weighted_value_index: result.weighted_value_index,
318
350
  selected_value: result.unwrapped_value,
319
351
  reason: result.wire_reason
320
352
  )
@@ -441,6 +473,21 @@ module Quonfig
441
473
  merge_contexts(@global_context, jit)
442
474
  end
443
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
+
444
491
  def normalize_context(ctx)
445
492
  return {} if ctx.nil?
446
493
  return ctx if ctx.is_a?(Hash)
@@ -485,6 +532,61 @@ module Quonfig
485
532
  nil
486
533
  end
487
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
+
488
590
  def typed_get(key, expected_type, default:, context:)
489
591
  jit = context == NO_DEFAULT_PROVIDED ? NO_DEFAULT_PROVIDED : context
490
592
  value = get(key, default, jit)
@@ -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,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ # Public details record returned by Quonfig::Client#get_*_details. Surfaces
5
+ # the resolution reason and (on error) an error_code/error_message alongside
6
+ # the resolved value, so downstream layers — most importantly the
7
+ # OpenFeature provider — can map the result without losing fidelity.
8
+ #
9
+ # +reason+ is one of the strings:
10
+ # "STATIC" — config has no targeting rules; matched value is the static default
11
+ # "TARGETING_MATCH" — a targeting rule matched (any non-ALWAYS_TRUE criterion)
12
+ # "SPLIT" — matched value came from a weighted variant
13
+ # "DEFAULT" — no rule matched (eval fell through)
14
+ # "ERROR" — evaluation failed
15
+ #
16
+ # +error_code+ (only when reason == "ERROR") is one of:
17
+ # "FLAG_NOT_FOUND" — the key was unknown to the store
18
+ # "TYPE_MISMATCH" — the resolved value didn't satisfy the requested type
19
+ # "GENERAL" — any other failure
20
+ class EvaluationDetails
21
+ REASON_STATIC = 'STATIC'
22
+ REASON_TARGETING_MATCH = 'TARGETING_MATCH'
23
+ REASON_SPLIT = 'SPLIT'
24
+ REASON_DEFAULT = 'DEFAULT'
25
+ REASON_ERROR = 'ERROR'
26
+
27
+ ERROR_FLAG_NOT_FOUND = 'FLAG_NOT_FOUND'
28
+ ERROR_TYPE_MISMATCH = 'TYPE_MISMATCH'
29
+ ERROR_GENERAL = 'GENERAL'
30
+
31
+ attr_reader :value, :reason, :error_code, :error_message
32
+
33
+ def initialize(value:, reason:, error_code: nil, error_message: nil)
34
+ @value = value
35
+ @reason = reason
36
+ @error_code = error_code
37
+ @error_message = error_message
38
+ end
39
+
40
+ def ==(other)
41
+ other.is_a?(EvaluationDetails) &&
42
+ other.value == @value &&
43
+ other.reason == @reason &&
44
+ other.error_code == @error_code &&
45
+ other.error_message == @error_message
46
+ end
47
+ alias eql? ==
48
+
49
+ def hash
50
+ [@value, @reason, @error_code, @error_message].hash
51
+ end
52
+
53
+ def inspect
54
+ parts = ["value=#{@value.inspect}", "reason=#{@reason.inspect}"]
55
+ parts << "error_code=#{@error_code.inspect}" if @error_code
56
+ parts << "error_message=#{@error_message.inspect}" if @error_message
57
+ "#<Quonfig::EvaluationDetails #{parts.join(' ')}>"
58
+ end
59
+ end
60
+ end
@@ -416,14 +416,20 @@ module Quonfig
416
416
  REASON_TARGETING_MATCH = 2
417
417
  REASON_SPLIT = 3
418
418
 
419
- attr_reader :value, :rule_index, :config
419
+ attr_reader :value, :rule_index, :config, :reportable_value
420
420
  attr_accessor :weighted_value_index
421
421
 
422
- def initialize(value:, rule_index:, config:, weighted_value_index: nil)
422
+ def initialize(value:, rule_index:, config:, weighted_value_index: nil, reportable_value: nil)
423
423
  @value = value
424
424
  @rule_index = rule_index
425
425
  @config = config
426
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
427
433
  end
428
434
 
429
435
  # Integer reason code for telemetry. Mirrors sdk-node's computeReason:
@@ -436,6 +442,19 @@ module Quonfig
436
442
  REASON_TARGETING_MATCH
437
443
  end
438
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
456
+ end
457
+
439
458
  # True if any rule on the config (default or environment) has a
440
459
  # non-ALWAYS_TRUE criterion. Used to decide STATIC vs TARGETING_MATCH.
441
460
  def self.targeting_rules?(config)
@@ -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
@@ -38,10 +39,38 @@ module Quonfig
38
39
  DEFAULT_MAX_EXAMPLE_CONTEXTS = 100_000
39
40
  DEFAULT_MAX_EVAL_SUMMARIES = 100_000
40
41
 
42
+ # Hardcoded fallback domain. Overridden by ENV['QUONFIG_DOMAIN'].
43
+ DEFAULT_DOMAIN = 'quonfig.com'
44
+
45
+ # Hardcoded fallback API URLs (used only when no QUONFIG_DOMAIN is set
46
+ # and no explicit api_urls are provided). Mirrors derive_api_urls(DEFAULT_DOMAIN).
41
47
  DEFAULT_API_URLS = [
42
48
  'https://primary.quonfig.com',
49
+ 'https://secondary.quonfig.com',
43
50
  ].freeze
44
51
 
52
+ # Resolve the active domain. Reads QUONFIG_DOMAIN; falls back to
53
+ # DEFAULT_DOMAIN. Mirrors `cli/src/util/domain-urls.ts#getDomain`.
54
+ def self.domain
55
+ env = ENV['QUONFIG_DOMAIN']
56
+ env && !env.empty? ? env : DEFAULT_DOMAIN
57
+ end
58
+
59
+ # Derive default api_urls for a given domain. e.g. for domain
60
+ # `quonfig-staging.com` returns
61
+ # `["https://primary.quonfig-staging.com", "https://secondary.quonfig-staging.com"]`.
62
+ def self.derive_api_urls(domain)
63
+ [
64
+ "https://primary.#{domain}",
65
+ "https://secondary.#{domain}",
66
+ ]
67
+ end
68
+
69
+ # Derive the telemetry URL for a given domain.
70
+ def self.derive_telemetry_url(domain)
71
+ "https://telemetry.#{domain}"
72
+ end
73
+
45
74
  # Derive the SSE stream URL for a given API URL by prepending `stream.` to
46
75
  # the hostname. Preserves scheme, port, and path.
47
76
  #
@@ -57,6 +86,7 @@ module Quonfig
57
86
 
58
87
  private def init(
59
88
  api_urls: nil,
89
+ telemetry_url: nil,
60
90
  sdk_key: ENV['QUONFIG_BACKEND_SDK_KEY'],
61
91
  environment: ENV['QUONFIG_ENVIRONMENT'],
62
92
  datadir: ENV['QUONFIG_DIR'],
@@ -74,7 +104,8 @@ module Quonfig
74
104
  collect_max_evaluation_summaries: DEFAULT_MAX_EVAL_SUMMARIES,
75
105
  allow_telemetry_in_local_mode: false,
76
106
  global_context: {},
77
- logger_key: nil
107
+ logger_key: nil,
108
+ enable_quonfig_user_context: false
78
109
  )
79
110
  @sdk_key = sdk_key
80
111
  @environment = environment
@@ -94,6 +125,7 @@ module Quonfig
94
125
  @is_fork = false
95
126
  @global_context = global_context
96
127
  @logger_key = logger_key
128
+ @enable_quonfig_user_context = enable_quonfig_user_context
97
129
 
98
130
  # defaults that may be overridden by context_upload_mode
99
131
  @collect_shapes = false
@@ -101,16 +133,19 @@ module Quonfig
101
133
  @collect_example_contexts = false
102
134
  @collect_max_example_contexts = 0
103
135
 
104
- if ENV['QUONFIG_API_URLS'] && ENV['QUONFIG_API_URLS'].length > 0
105
- api_urls = ENV['QUONFIG_API_URLS']
106
- end
136
+ # URL resolution order (highest wins):
137
+ # 1. Explicit kwargs (api_urls:, telemetry_url:)
138
+ # 2. ENV['QUONFIG_DOMAIN'] -> derives all three
139
+ # 3. Hardcoded DEFAULT_DOMAIN ('quonfig.com')
140
+ domain = Quonfig::Options.domain
107
141
 
108
- @api_urls = Array(api_urls || DEFAULT_API_URLS).map { |url| remove_trailing_slash(url) }
142
+ @api_urls = Array(api_urls || Quonfig::Options.derive_api_urls(domain))
143
+ .map { |url| remove_trailing_slash(url) }
109
144
 
110
145
  @sse_api_urls = @api_urls.map { |url| Quonfig::Options.derive_stream_url(url) }
111
146
  @config_api_urls = @api_urls
112
147
 
113
- @telemetry_destination = ENV['QUONFIG_TELEMETRY_URL'] || derive_telemetry_destination(@api_urls)
148
+ @telemetry_destination = telemetry_url || Quonfig::Options.derive_telemetry_url(domain)
114
149
 
115
150
  case context_upload_mode
116
151
  when :none
@@ -185,16 +220,5 @@ module Quonfig
185
220
  def remove_trailing_slash(url)
186
221
  url.end_with?('/') ? url[0..-2] : url
187
222
  end
188
-
189
- # Derive a telemetry URL from the configured api_urls by swapping the
190
- # primary/secondary host prefix for `telemetry` on a *.quonfig.com host.
191
- # Falls back to https://telemetry.quonfig.com if no URL matches.
192
- def derive_telemetry_destination(api_urls)
193
- api_urls.each do |api_url|
194
- match = api_url.match(%r{\Ahttps?://(?:primary|secondary)\.([^/]*quonfig\.com)}i)
195
- return "https://telemetry.#{match[1]}" if match
196
- end
197
- 'https://telemetry.quonfig.com'
198
- end
199
223
  end
200
224
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+
3
5
  module Quonfig
4
6
  # Public-API resolver: looks up a config by key in a ConfigStore and runs
5
7
  # it through an Evaluator against a Context.
@@ -15,6 +17,11 @@ module Quonfig
15
17
  # Quonfig::ConfigResolver — the two coexist during the JSON migration.
16
18
  class Resolver
17
19
  TRUE_VALUES = %w[true 1 t yes].freeze
20
+ # Prefix the eval-summary aggregator stamps onto redacted confidential
21
+ # values before the 5-char MD5 hash. Matches CONFIDENTIAL_PREFIX in
22
+ # ReforgeHQ/sdk-ruby/lib/reforge/config_value_unwrapper.rb so dashboards
23
+ # built against the predecessor wire format keep working.
24
+ CONFIDENTIAL_PREFIX = '*****'
18
25
 
19
26
  attr_reader :store, :evaluator
20
27
  attr_accessor :project_env_id
@@ -50,7 +57,8 @@ module Quonfig
50
57
  value: resolved_value,
51
58
  rule_index: eval_result.rule_index,
52
59
  config: config,
53
- weighted_value_index: weighted_index
60
+ weighted_value_index: weighted_index,
61
+ reportable_value: redacted_reportable_value(eval_result.value)
54
62
  )
55
63
  end
56
64
 
@@ -87,6 +95,26 @@ module Quonfig
87
95
 
88
96
  private
89
97
 
98
+ # If +value+ is confidential or has a decryptWith key, return the
99
+ # `*****<5-hex>` redacted string the eval-summary telemetry aggregator
100
+ # should ship in place of the resolved plaintext. The hash is computed
101
+ # over the raw `value[:value]` (ciphertext when decryptWith is set,
102
+ # plaintext-as-stored when only `confidential: true`) — matches
103
+ # ReforgeHQ/sdk-ruby ConfigValueUnwrapper#reportable_wrapped_value
104
+ # (CONFIDENTIAL_PREFIX + first 5 chars of MD5).
105
+ def redacted_reportable_value(value)
106
+ return nil if value.nil?
107
+
108
+ confidential = vget(value, :confidential, 'confidential')
109
+ decrypt_with = vget(value, :decryptWith, 'decryptWith', :decrypt_with, 'decrypt_with')
110
+ return nil unless confidential || (decrypt_with && !decrypt_with.to_s.empty?)
111
+
112
+ raw = vget(value, :value, 'value')
113
+ return nil if raw.nil?
114
+
115
+ "#{CONFIDENTIAL_PREFIX}#{Digest::MD5.hexdigest(raw.to_s)[0, 5]}"
116
+ end
117
+
90
118
  def vget(hash, *keys)
91
119
  return nil if hash.nil?
92
120
 
@@ -179,7 +207,7 @@ module Quonfig
179
207
  begin
180
208
  plaintext = Quonfig::Encryption.new(secret_key).decrypt(ciphertext)
181
209
  rescue StandardError => e
182
- raise Quonfig::Errors::DecryptionError.new(config_key(config), e.message)
210
+ raise Quonfig::Errors::DecryptionError.new(config_key(config), e.message), cause: e
183
211
  end
184
212
 
185
213
  {
@@ -153,11 +153,23 @@ module Quonfig
153
153
  @at_exit_registered = true
154
154
  end
155
155
 
156
+ # Wait this long for the background reporter thread to exit before
157
+ # giving up. Bounded so a thread blocked on a dead telemetry endpoint
158
+ # can't hang process exit.
159
+ AT_EXIT_THREAD_JOIN_TIMEOUT_SECONDS = 1.0
160
+
156
161
  # Idempotent final drain. Safe to call after #stop has already
157
162
  # drained: aggregators return nil when empty and #sync becomes a
158
- # no-op.
163
+ # no-op. Bounded so a stuck reporter thread or dead telemetry
164
+ # endpoint can't hang process exit.
159
165
  def final_drain_on_exit
160
166
  @stopped.make_true
167
+ thread = @thread
168
+ @thread = nil
169
+ if thread&.alive?
170
+ thread.wakeup
171
+ thread.join(AT_EXIT_THREAD_JOIN_TIMEOUT_SECONDS)
172
+ end
161
173
  sync
162
174
  rescue StandardError => e
163
175
  LOG.debug "[quonfig] at_exit telemetry drain failed: #{e.class}: #{e.message}"
data/lib/quonfig.rb CHANGED
@@ -25,6 +25,7 @@ require 'quonfig/error'
25
25
  require 'quonfig/duration'
26
26
  require 'quonfig/reason'
27
27
  require 'quonfig/evaluation'
28
+ require 'quonfig/evaluation_details'
28
29
  require 'quonfig/encryption'
29
30
  require 'quonfig/exponential_backoff'
30
31
  require 'quonfig/periodic_sync'
@@ -39,6 +40,7 @@ require 'quonfig/errors/decryption_error'
39
40
  require 'quonfig/errors/missing_environment_error'
40
41
  require 'quonfig/errors/invalid_environment_error'
41
42
  require 'quonfig/options'
43
+ require 'quonfig/dev_context'
42
44
  require 'quonfig/rate_limit_cache'
43
45
  require 'quonfig/weighted_value_resolver'
44
46
  require 'quonfig/config_store'
data/quonfig.gemspec CHANGED
@@ -2,16 +2,16 @@
2
2
  # DO NOT EDIT THIS FILE DIRECTLY
3
3
  # Instead, edit Juwelier::Tasks in Rakefile, and run 'rake gemspec'
4
4
  # -*- encoding: utf-8 -*-
5
- # stub: quonfig 0.0.8 ruby lib
5
+ # stub: quonfig 0.0.10 ruby lib
6
6
 
7
7
  Gem::Specification.new do |s|
8
8
  s.name = "quonfig".freeze
9
- s.version = "0.0.8".freeze
9
+ s.version = "0.0.10".freeze
10
10
 
11
11
  s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
12
12
  s.require_paths = ["lib".freeze]
13
13
  s.authors = ["Jeff Dwyer".freeze]
14
- s.date = "2026-04-26"
14
+ s.date = "2026-05-01"
15
15
  s.description = "Quonfig \u2014 feature flags and live config, stored as files in git.".freeze
16
16
  s.email = "jeff@quonfig.com".freeze
17
17
  s.extra_rdoc_files = [
@@ -54,6 +54,7 @@ Gem::Specification.new do |s|
54
54
  "lib/quonfig/config_store.rb",
55
55
  "lib/quonfig/context.rb",
56
56
  "lib/quonfig/datadir.rb",
57
+ "lib/quonfig/dev_context.rb",
57
58
  "lib/quonfig/duration.rb",
58
59
  "lib/quonfig/encryption.rb",
59
60
  "lib/quonfig/error.rb",
@@ -68,6 +69,7 @@ Gem::Specification.new do |s|
68
69
  "lib/quonfig/errors/type_mismatch_error.rb",
69
70
  "lib/quonfig/errors/uninitialized_error.rb",
70
71
  "lib/quonfig/evaluation.rb",
72
+ "lib/quonfig/evaluation_details.rb",
71
73
  "lib/quonfig/evaluator.rb",
72
74
  "lib/quonfig/exponential_backoff.rb",
73
75
  "lib/quonfig/fixed_size_hash.rb",
@@ -96,6 +98,7 @@ Gem::Specification.new do |s|
96
98
  "test/fixtures/datafile.json",
97
99
  "test/integration/test_context_precedence.rb",
98
100
  "test/integration/test_datadir_environment.rb",
101
+ "test/integration/test_dev_overrides.rb",
99
102
  "test/integration/test_enabled.rb",
100
103
  "test/integration/test_enabled_with_contexts.rb",
101
104
  "test/integration/test_get.rb",
@@ -119,6 +122,8 @@ Gem::Specification.new do |s|
119
122
  "test/test_context_shape.rb",
120
123
  "test/test_context_shape_aggregator.rb",
121
124
  "test/test_datadir.rb",
125
+ "test/test_details_getters.rb",
126
+ "test/test_dev_context.rb",
122
127
  "test/test_duration.rb",
123
128
  "test/test_encryption.rb",
124
129
  "test/test_evaluation_summaries_aggregator.rb",