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 +4 -4
- data/CHANGELOG.md +17 -0
- data/CLAUDE.md +1 -1
- data/README.md +4 -4
- data/VERSION +1 -1
- data/lib/quonfig/bound_client.rb +26 -0
- data/lib/quonfig/client.rb +104 -2
- data/lib/quonfig/dev_context.rb +41 -0
- data/lib/quonfig/evaluation_details.rb +60 -0
- data/lib/quonfig/evaluator.rb +21 -2
- data/lib/quonfig/options.rb +41 -17
- data/lib/quonfig/resolver.rb +30 -2
- data/lib/quonfig/telemetry/telemetry_reporter.rb +13 -1
- data/lib/quonfig.rb +2 -0
- data/quonfig.gemspec +8 -3
- data/test/integration/test_dev_overrides.rb +40 -0
- data/test/integration/test_helpers.rb +34 -1
- data/test/integration/test_telemetry.rb +14 -0
- data/test/test_client_telemetry.rb +43 -0
- data/test/test_details_getters.rb +242 -0
- data/test/test_dev_context.rb +163 -0
- data/test/test_http_connection.rb +6 -4
- data/test/test_options.rb +53 -22
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 94866b65b3e3c4e834897981847da01f350984b2f7126f8259318ba385b8fd77
|
|
4
|
+
data.tar.gz: bc173a4300c1475f596921de2a2d24e02e5de648503318491a602c19123dd329
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
- `
|
|
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
|
-
| `
|
|
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>` | `[
|
|
141
|
-
| `telemetry_url` | `String` | `https://telemetry
|
|
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.
|
|
1
|
+
0.0.10
|
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
|
@@ -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)
|
|
@@ -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:
|
|
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
|
data/lib/quonfig/evaluator.rb
CHANGED
|
@@ -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)
|
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
|
|
@@ -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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 ||
|
|
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 =
|
|
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
|
data/lib/quonfig/resolver.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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-
|
|
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",
|