openfeature-sdk 0.6.1 → 0.6.3

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: ac3b2793c1b6453e2525dcc04d7646fd4e5d61a87e6ade8b257754cf9b9c991c
4
- data.tar.gz: c5aeedfe23851ef99e26024bf1db5f4ba38d7f669b9ea6b3e1e5f63d0693c1a6
3
+ metadata.gz: bd3ec1b654a844eaa7bcafe400d6d2075199fa49ba6823aad1c8e1ce8c05a526
4
+ data.tar.gz: 3d159cb4f4df832154cfa7a4657ee53f357827a3d55dcbe124bf4716248e24b8
5
5
  SHA512:
6
- metadata.gz: 9aa41845c24248cf6eec381f4531fbfc301196ea748f3b3189333c4879c64a2dc1eda8a935e3514291dcbb5100c1668260e1b87bec837952e1eb1777ae083501
7
- data.tar.gz: c162a21b0a5b4ee91f132e676440b84cf7862afccd689416f97e9b41f9284bf8b86de5050a1c32a3c98096ec39074cbb0ae1dcbe0d85d4db905d447f3baccd3d
6
+ metadata.gz: 66667a224162e00b882ad73b45e5d2825cc35f47007ca961749cc7115cbe1223d881be0cfff89eb4a5d3fd3f0235c2548f7a89420de9ff7bb3a6b0fd4bcada65
7
+ data.tar.gz: f799ac085e5593bc9b14a040ebddc8326c57ff46e36285ed0ea9b44008dd0d8d58a149a1c0f7fc9be50aeb3cf8d60ffc2859f2b3f9099d49e1db3df6f795b669
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.6.1"
2
+ ".": "0.6.3"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.3](https://github.com/open-feature/ruby-sdk/compare/v0.6.2...v0.6.3) (2026-03-07)
4
+
5
+
6
+ ### Features
7
+
8
+ * close spec compliance gaps for OpenFeature v0.8.0 ([#237](https://github.com/open-feature/ruby-sdk/issues/237)) ([9a87d04](https://github.com/open-feature/ruby-sdk/commit/9a87d04d5f261ea06e073f405c15613db7099d8a))
9
+ * enable Gherkin feature tests ([#50](https://github.com/open-feature/ruby-sdk/issues/50)) ([#233](https://github.com/open-feature/ruby-sdk/issues/233)) ([95845ba](https://github.com/open-feature/ruby-sdk/commit/95845ba6ec26357d9c0895d310361e411f85da11))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * close remaining MUST-level spec compliance gaps ([#238](https://github.com/open-feature/ruby-sdk/issues/238)) ([1d08491](https://github.com/open-feature/ruby-sdk/commit/1d084911964c8672dd66b23834eec6f14e453749))
15
+
16
+ ## [0.6.2](https://github.com/open-feature/ruby-sdk/compare/v0.6.1...v0.6.2) (2026-03-07)
17
+
18
+
19
+ ### Features
20
+
21
+ * add logging hook (spec Appendix A) ([#229](https://github.com/open-feature/ruby-sdk/issues/229)) ([2f681c9](https://github.com/open-feature/ruby-sdk/commit/2f681c910198d2bfa16389018f42ca9dc3270936))
22
+ * add transaction context propagation (spec 3.3) ([#230](https://github.com/open-feature/ruby-sdk/issues/230)) ([0aff30f](https://github.com/open-feature/ruby-sdk/commit/0aff30f77a0b680341cfd3d1f43e9d1f0ede1b75))
23
+
3
24
  ## [0.6.1](https://github.com/open-feature/ruby-sdk/compare/v0.6.0...v0.6.1) (2026-03-05)
4
25
 
5
26
 
data/Gemfile CHANGED
@@ -4,3 +4,6 @@ source "https://rubygems.org"
4
4
 
5
5
  # Specify your gem's dependencies in openfeature-sdk.gemspec
6
6
  gemspec
7
+
8
+ gem "cucumber", "~> 10.0", group: :test
9
+ gem "logger", group: :test
data/Gemfile.lock CHANGED
@@ -1,12 +1,40 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- openfeature-sdk (0.6.1)
4
+ openfeature-sdk (0.6.3)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
8
8
  specs:
9
9
  ast (2.4.3)
10
+ base64 (0.3.0)
11
+ bigdecimal (4.0.1)
12
+ builder (3.3.0)
13
+ cucumber (10.2.0)
14
+ base64 (~> 0.2)
15
+ builder (~> 3.2)
16
+ cucumber-ci-environment (> 9, < 12)
17
+ cucumber-core (> 15, < 17)
18
+ cucumber-cucumber-expressions (> 17, < 20)
19
+ cucumber-html-formatter (> 21, < 23)
20
+ diff-lcs (~> 1.5)
21
+ logger (~> 1.6)
22
+ mini_mime (~> 1.1)
23
+ multi_test (~> 1.1)
24
+ sys-uname (~> 1.3)
25
+ cucumber-ci-environment (11.0.0)
26
+ cucumber-core (16.2.0)
27
+ cucumber-gherkin (> 36, < 40)
28
+ cucumber-messages (> 31, < 33)
29
+ cucumber-tag-expressions (> 6, < 9)
30
+ cucumber-cucumber-expressions (19.0.0)
31
+ bigdecimal
32
+ cucumber-gherkin (39.0.0)
33
+ cucumber-messages (>= 31, < 33)
34
+ cucumber-html-formatter (22.3.0)
35
+ cucumber-messages (> 23, < 33)
36
+ cucumber-messages (32.2.0)
37
+ cucumber-tag-expressions (8.1.0)
10
38
  date (3.5.1)
11
39
  debug (1.11.1)
12
40
  irb (~> 1.10)
@@ -14,6 +42,7 @@ GEM
14
42
  diff-lcs (1.6.2)
15
43
  docile (1.4.1)
16
44
  erb (6.0.2)
45
+ ffi (1.17.3)
17
46
  io-console (0.8.2)
18
47
  irb (1.17.0)
19
48
  pp (>= 0.6.0)
@@ -23,7 +52,11 @@ GEM
23
52
  json (2.18.1)
24
53
  language_server-protocol (3.17.0.5)
25
54
  lint_roller (1.1.0)
55
+ logger (1.7.0)
26
56
  markly (0.15.2)
57
+ memoist3 (1.0.0)
58
+ mini_mime (1.1.5)
59
+ multi_test (1.1.0)
27
60
  parallel (1.27.0)
28
61
  parser (3.3.10.2)
29
62
  ast (~> 2.4.1)
@@ -100,6 +133,9 @@ GEM
100
133
  lint_roller (~> 1.1)
101
134
  rubocop-performance (~> 1.26.0)
102
135
  stringio (3.2.0)
136
+ sys-uname (1.5.0)
137
+ ffi (~> 1.1)
138
+ memoist3 (~> 1.0.0)
103
139
  timecop (0.9.10)
104
140
  tsort (0.2.0)
105
141
  unicode-display_width (3.2.0)
@@ -120,7 +156,9 @@ PLATFORMS
120
156
  x86_64-linux
121
157
 
122
158
  DEPENDENCIES
159
+ cucumber (~> 10.0)
123
160
  debug
161
+ logger
124
162
  markly
125
163
  openfeature-sdk!
126
164
  rake (~> 13.0)
data/README.md CHANGED
@@ -17,8 +17,8 @@
17
17
  </a>
18
18
  <!-- x-release-please-start-version -->
19
19
 
20
- <a href="https://github.com/open-feature/ruby-sdk/releases/tag/v0.6.1">
21
- <img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.6.1&color=blue&style=for-the-badge" />
20
+ <a href="https://github.com/open-feature/ruby-sdk/releases/tag/v0.6.3">
21
+ <img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.6.3&color=blue&style=for-the-badge" />
22
22
  </a>
23
23
 
24
24
  <!-- x-release-please-end -->
@@ -101,12 +101,12 @@ object = client.fetch_object_value(flag_key: 'object_value', default_value: { na
101
101
  | ✅ | [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. |
102
102
  | ✅ | [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
103
103
  | ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
104
- | | [Logging](#logging) | Integrate with popular logging packages. |
104
+ | | [Logging](#logging) | Integrate with popular logging packages. |
105
105
  | ✅ | [Domains](#domains) | Logically bind clients with providers. |
106
106
  | ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. |
107
107
  | ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
108
108
  | ✅ | [Tracking](#tracking) | Associate user actions with feature flag evaluations for experimentation. |
109
- | | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread) |
109
+ | | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread) |
110
110
  | ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
111
111
 
112
112
  <sub>Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌</sub>
@@ -247,9 +247,27 @@ client.fetch_boolean_value(
247
247
 
248
248
  ### Logging
249
249
 
250
- Coming Soon! [Issue available](https://github.com/open-feature/ruby-sdk/issues/148) to work on.
250
+ The SDK includes a built-in `LoggingHook` that provides structured log output for flag evaluations. It logs at the `before`, `after`, and `error` stages of the hook lifecycle.
251
251
 
252
- <!-- TODO: talk about logging config and include a code example -->
252
+ ```ruby
253
+ # Use the SDK's default logger (from Configuration)
254
+ OpenFeature::SDK.hooks << OpenFeature::SDK::Hooks::LoggingHook.new
255
+
256
+ # Or provide your own logger
257
+ logger = Logger.new($stdout)
258
+ OpenFeature::SDK.hooks << OpenFeature::SDK::Hooks::LoggingHook.new(logger: logger)
259
+
260
+ # Optionally include evaluation context in log output
261
+ OpenFeature::SDK.hooks << OpenFeature::SDK::Hooks::LoggingHook.new(
262
+ logger: logger,
263
+ include_evaluation_context: true
264
+ )
265
+ ```
266
+
267
+ Log output uses a structured key=value format:
268
+ - **before** (DEBUG): `stage=before domain=my-domain provider_name=my-provider flag_key=my-flag default_value=false`
269
+ - **after** (DEBUG): includes `reason`, `variant`, and `value`
270
+ - **error** (ERROR): includes `error_code` and `error_message`
253
271
 
254
272
  ### Domains
255
273
 
@@ -361,12 +379,44 @@ Check the documentation for your [provider](#providers) for more information.
361
379
 
362
380
  ### Transaction Context Propagation
363
381
 
364
- Coming Soon! [Issue available](https://github.com/open-feature/ruby-sdk/issues/150) to be worked on.
382
+ Transaction context is a container for transaction-specific evaluation context (e.g. user id, user agent, IP).
383
+ Transaction context can be set where specific data is available (e.g. an auth service or request handler) and by using the transaction context propagator it will automatically be applied to all flag evaluations within a transaction (e.g. a request or thread).
384
+
385
+ The SDK ships with a `ThreadLocalTransactionContextPropagator` that stores context in `Thread.current`:
386
+
387
+ ```ruby
388
+ # Set up the propagator
389
+ OpenFeature::SDK.set_transaction_context_propagator(
390
+ OpenFeature::SDK::ThreadLocalTransactionContextPropagator.new
391
+ )
392
+
393
+ # Set transaction context (e.g. in a request middleware)
394
+ OpenFeature::SDK.set_transaction_context(
395
+ OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123", "email" => "user@example.com")
396
+ )
397
+
398
+ # Transaction context is automatically merged during flag evaluation.
399
+ # Merge precedence: invocation > client > transaction > API (global)
400
+ client = OpenFeature::SDK.build_client
401
+ client.fetch_boolean_value(flag_key: "my-flag", default_value: false)
402
+ ```
403
+
404
+ You can implement a custom propagator by including the `TransactionContextPropagator` module:
365
405
 
366
- <!-- Transaction context is a container for transaction-specific evaluation context (e.g. user id, user agent, IP).
367
- Transaction context can be set where specific data is available (e.g. an auth service or request handler) and by using the transaction context propagator it will automatically be applied to all flag evaluations within a transaction (e.g. a request or thread). -->
406
+ ```ruby
407
+ class MyRequestScopedPropagator
408
+ include OpenFeature::SDK::TransactionContextPropagator
368
409
 
369
- <!-- TODO: code example for global shutdown -->
410
+ def set_transaction_context(evaluation_context)
411
+ # Store context in your request-scoped storage
412
+ RequestStore[:openfeature_context] = evaluation_context
413
+ end
414
+
415
+ def get_transaction_context
416
+ RequestStore[:openfeature_context]
417
+ end
418
+ end
419
+ ```
370
420
 
371
421
  ## Extending
372
422
 
data/Rakefile CHANGED
@@ -7,4 +7,9 @@ RSpec::Core::RakeTask.new(:spec)
7
7
 
8
8
  require "standard/rake"
9
9
 
10
+ desc "Run Cucumber Gherkin feature tests"
11
+ task :cucumber do
12
+ sh "bundle exec cucumber"
13
+ end
14
+
10
15
  task default: %i[spec standard]
data/cucumber.yml ADDED
@@ -0,0 +1,2 @@
1
+ default: spec/open-feature-spec/specification/assets/gherkin --require features --tags "not @deprecated" --publish-quiet
2
+ deprecated: spec/open-feature-spec/specification/assets/gherkin --require features --publish-quiet
@@ -34,7 +34,7 @@ module OpenFeature
34
34
  include Singleton # Satisfies Flag Evaluation API Requirement 1.1.1
35
35
  extend Forwardable
36
36
 
37
- def_delegators :configuration, :provider, :set_provider, :set_provider_and_wait, :hooks, :evaluation_context
37
+ def_delegators :configuration, :provider, :set_provider, :set_provider_and_wait, :hooks, :add_hooks, :evaluation_context
38
38
 
39
39
  def configuration
40
40
  @configuration ||= Configuration.new
@@ -54,6 +54,11 @@ module OpenFeature
54
54
  Client.new(provider: Provider::NoOpProvider.new, evaluation_context:)
55
55
  end
56
56
 
57
+ def provider_metadata(domain: nil)
58
+ prov = provider(domain: domain)
59
+ prov.metadata if prov&.respond_to?(:metadata)
60
+ end
61
+
57
62
  def add_handler(event_type, handler)
58
63
  configuration.add_handler(event_type, handler)
59
64
  end
@@ -70,6 +75,15 @@ module OpenFeature
70
75
  configuration.logger = new_logger
71
76
  end
72
77
 
78
+ def set_transaction_context_propagator(propagator)
79
+ configuration.transaction_context_propagator = propagator
80
+ end
81
+
82
+ def set_transaction_context(evaluation_context)
83
+ propagator = configuration.transaction_context_propagator
84
+ propagator&.set_transaction_context(evaluation_context)
85
+ end
86
+
73
87
  def shutdown
74
88
  configuration.shutdown
75
89
  end
@@ -28,6 +28,10 @@ module OpenFeature
28
28
  @hooks = []
29
29
  end
30
30
 
31
+ def add_hooks(*new_hooks)
32
+ @hooks.concat(new_hooks.flatten)
33
+ end
34
+
31
35
  def provider_status
32
36
  OpenFeature::SDK.configuration.provider_state(@provider)
33
37
  end
@@ -49,11 +53,7 @@ module OpenFeature
49
53
  def track(tracking_event_name, evaluation_context: nil, tracking_event_details: nil)
50
54
  return unless @provider.respond_to?(:track)
51
55
 
52
- built_context = EvaluationContextBuilder.new.call(
53
- api_context: OpenFeature::SDK.evaluation_context,
54
- client_context: self.evaluation_context,
55
- invocation_context: evaluation_context
56
- )
56
+ built_context = build_evaluation_context(evaluation_context)
57
57
 
58
58
  @provider.track(tracking_event_name, evaluation_context: built_context, tracking_event_details: tracking_event_details)
59
59
  end
@@ -74,31 +74,40 @@ module OpenFeature
74
74
  def fetch_details(type:, flag_key:, default_value:, evaluation_context: nil, invocation_hooks: [], hook_hints: nil)
75
75
  validate_default_value_type(type, default_value)
76
76
 
77
+ built_context = build_evaluation_context(evaluation_context)
78
+
79
+ # Assemble ordered hooks: API → Client → Invocation → Provider (spec 4.4.2)
80
+ provider_hooks = @provider.respond_to?(:hooks) ? Array(@provider.hooks) : []
81
+ ordered_hooks = [*OpenFeature::SDK.hooks, *@hooks, *invocation_hooks, *provider_hooks]
82
+
83
+ # Check for short-circuit conditions (spec 1.7.6 + 1.7.7)
84
+ short_circuit_code = nil
77
85
  if OpenFeature::SDK.configuration.provider_tracked?(@provider)
78
- error_code = short_circuit_error_code(provider_status)
79
- if error_code
86
+ short_circuit_code = short_circuit_error_code(provider_status)
87
+ end
88
+
89
+ # Fast path: skip hook ceremony when no hooks are registered
90
+ if ordered_hooks.empty?
91
+ if short_circuit_code
80
92
  resolution = Provider::ResolutionDetails.new(
81
93
  value: default_value,
82
- error_code: error_code,
94
+ error_code: short_circuit_code,
83
95
  reason: Provider::Reason::ERROR
84
96
  )
85
97
  return EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution)
86
98
  end
87
- end
88
-
89
- built_context = EvaluationContextBuilder.new.call(
90
- api_context: OpenFeature::SDK.evaluation_context,
91
- client_context: self.evaluation_context,
92
- invocation_context: evaluation_context
93
- )
94
-
95
- # Assemble ordered hooks: API → Client → Invocation → Provider (spec 4.4.2)
96
- provider_hooks = @provider.respond_to?(:hooks) ? Array(@provider.hooks) : []
97
- ordered_hooks = [*OpenFeature::SDK.hooks, *@hooks, *invocation_hooks, *provider_hooks]
98
99
 
99
- # Fast path: skip hook ceremony when no hooks are registered
100
- if ordered_hooks.empty?
101
- return evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: built_context)
100
+ begin
101
+ return evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: built_context)
102
+ rescue => e
103
+ resolution = Provider::ResolutionDetails.new(
104
+ value: default_value,
105
+ error_code: Provider::ErrorCode::GENERAL,
106
+ reason: Provider::Reason::ERROR,
107
+ error_message: e.message
108
+ )
109
+ return EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution)
110
+ end
102
111
  end
103
112
 
104
113
  hook_context = Hooks::HookContext.new(
@@ -119,8 +128,21 @@ module OpenFeature
119
128
  end
120
129
 
121
130
  executor = Hooks::HookExecutor.new(logger: OpenFeature::SDK.configuration.logger)
122
- executor.execute(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints) do |hctx|
123
- evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
131
+
132
+ if short_circuit_code
133
+ # Spec 1.7.6 + 1.7.7: short-circuit must still run error hooks and finally hooks
134
+ resolution = Provider::ResolutionDetails.new(
135
+ value: default_value,
136
+ error_code: short_circuit_code,
137
+ reason: Provider::Reason::ERROR
138
+ )
139
+ evaluation_details = EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution)
140
+ executor.run_short_circuit(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints, evaluation_details: evaluation_details)
141
+ evaluation_details
142
+ else
143
+ executor.execute(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints) do |hctx|
144
+ evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
145
+ end
124
146
  end
125
147
  end
126
148
 
@@ -136,6 +158,7 @@ module OpenFeature
136
158
  resolution_details.value = default_value
137
159
  resolution_details.error_code = Provider::ErrorCode::TYPE_MISMATCH
138
160
  resolution_details.reason = Provider::Reason::ERROR
161
+ resolution_details.variant = nil
139
162
  end
140
163
 
141
164
  EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution_details)
@@ -148,6 +171,18 @@ module OpenFeature
148
171
  end
149
172
  end
150
173
 
174
+ def build_evaluation_context(invocation_context)
175
+ propagator = OpenFeature::SDK.configuration.transaction_context_propagator
176
+ transaction_context = propagator&.get_transaction_context
177
+
178
+ EvaluationContextBuilder.new.call(
179
+ api_context: OpenFeature::SDK.evaluation_context,
180
+ transaction_context: transaction_context,
181
+ client_context: evaluation_context,
182
+ invocation_context: invocation_context
183
+ )
184
+ end
185
+
151
186
  def validate_default_value_type(type, default_value)
152
187
  expected_classes = TYPE_CLASS_MAP[type]
153
188
  unless expected_classes.any? { |klass| default_value.is_a?(klass) }
@@ -2,6 +2,8 @@
2
2
 
3
3
  module OpenFeature
4
4
  module SDK
5
- ClientMetadata = Struct.new(:domain, keyword_init: true)
5
+ ClientMetadata = Struct.new(:domain, keyword_init: true) do
6
+ alias_method :name, :domain
7
+ end
6
8
  end
7
9
  end
@@ -17,7 +17,7 @@ module OpenFeature
17
17
  class Configuration
18
18
  extend Forwardable
19
19
 
20
- attr_accessor :evaluation_context, :hooks
20
+ attr_accessor :evaluation_context, :hooks, :transaction_context_propagator
21
21
  attr_reader :logger
22
22
 
23
23
  def initialize
@@ -31,6 +31,10 @@ module OpenFeature
31
31
  @client_handlers_mutex = Mutex.new
32
32
  end
33
33
 
34
+ def add_hooks(*new_hooks)
35
+ @hooks.concat(new_hooks.flatten)
36
+ end
37
+
34
38
  def provider(domain: nil)
35
39
  @providers[domain] || @providers[nil]
36
40
  end
@@ -85,9 +89,11 @@ module OpenFeature
85
89
  end
86
90
 
87
91
  def shutdown
88
- providers_to_shutdown = @provider_mutex.synchronize { @providers.values.uniq }
92
+ providers_to_shutdown = @provider_mutex.synchronize { @providers.values.uniq(&:object_id) }
89
93
 
90
94
  providers_to_shutdown.each do |prov|
95
+ # Spec 1.7.9: Set provider state to NOT_READY before shutdown
96
+ @provider_state_registry.set_initial_state(prov, ProviderState::NOT_READY)
91
97
  prov.shutdown if prov.respond_to?(:shutdown)
92
98
  rescue => e
93
99
  @logger&.warn("Error shutting down provider #{prov&.class&.name || "unknown"}: #{e.message}")
@@ -107,34 +113,46 @@ module OpenFeature
107
113
  @provider_mutex.synchronize do
108
114
  @providers.clear
109
115
  end
116
+ @hooks.clear
117
+ @evaluation_context = nil
118
+ @transaction_context_propagator = nil
110
119
  end
111
120
 
112
121
  def set_provider_internal(provider, domain:, wait_for_init:)
113
122
  # Capture evaluation context before acquiring mutex to prevent race conditions
114
123
  context_for_init = @evaluation_context
115
124
 
116
- old_provider, provider_to_init = nil
125
+ old_provider = nil
126
+ needs_init = false
127
+ needs_shutdown = false
117
128
 
118
129
  @provider_mutex.synchronize do
119
130
  old_provider = @providers[domain]
120
131
 
121
- # Remove old provider state to prevent memory leaks
122
- @provider_state_registry.remove_provider(old_provider)
123
-
124
132
  new_providers = @providers.dup
125
133
  new_providers[domain] = provider
126
134
  @providers = new_providers
127
135
 
128
- @provider_state_registry.set_initial_state(provider)
136
+ # Spec 1.1.2.2: Only initialize if the provider is not already active
137
+ # (i.e., not already bound to another domain)
138
+ already_active = @providers.any? { |d, p| d != domain && p.equal?(provider) && @provider_state_registry.tracked?(p) }
139
+ needs_init = !already_active
129
140
 
130
- provider.send(:attach, self) if provider.is_a?(Provider::EventEmitter)
141
+ if needs_init
142
+ @provider_state_registry.set_initial_state(provider)
143
+ provider.send(:attach, self) if provider.is_a?(Provider::EventEmitter)
144
+ end
131
145
 
132
- provider_to_init = provider
146
+ # Spec 1.1.2.3: Only shutdown old provider if it's no longer bound to any domain
147
+ if old_provider && !old_provider.equal?(provider)
148
+ still_bound = @providers.any? { |_, p| p.equal?(old_provider) }
149
+ needs_shutdown = !still_bound
150
+ @provider_state_registry.remove_provider(old_provider) unless still_bound
151
+ end
133
152
  end
134
153
 
135
154
  # Shutdown old provider outside mutex to avoid blocking other operations
136
- # Only shutdown if it's a different provider to prevent race condition
137
- if old_provider && old_provider != provider
155
+ if needs_shutdown
138
156
  begin
139
157
  old_provider.shutdown if old_provider.respond_to?(:shutdown)
140
158
  rescue => e
@@ -143,12 +161,17 @@ module OpenFeature
143
161
  end
144
162
 
145
163
  # Initialize provider outside the mutex to avoid blocking other operations
146
- if wait_for_init
147
- init_provider(provider_to_init, context_for_init, raise_on_error: true)
148
- else
149
- Thread.new do
150
- init_provider(provider_to_init, context_for_init, raise_on_error: false)
164
+ if needs_init
165
+ if wait_for_init
166
+ init_provider(provider, context_for_init, raise_on_error: true)
167
+ else
168
+ Thread.new do
169
+ init_provider(provider, context_for_init, raise_on_error: false)
170
+ end
151
171
  end
172
+ elsif wait_for_init
173
+ # Provider already active; no init needed but still dispatch READY
174
+ dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY)
152
175
  end
153
176
  end
154
177
 
@@ -164,16 +187,22 @@ module OpenFeature
164
187
 
165
188
  dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY)
166
189
  rescue => e
190
+ # Spec 1.7.8: Propagate error code from provider if available
191
+ error_code = if e.respond_to?(:error_code) && e.error_code
192
+ e.error_code
193
+ else
194
+ Provider::ErrorCode::GENERAL
195
+ end
196
+
167
197
  dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR,
168
- error_code: Provider::ErrorCode::GENERAL,
198
+ error_code: error_code,
169
199
  message: e.message)
170
200
 
171
201
  if raise_on_error
172
- # Re-raise as ProviderInitializationError for synchronous callers
173
202
  raise ProviderInitializationError.new(
174
203
  "Provider #{provider.class.name} initialization failed: #{e.message}",
175
204
  provider:,
176
- error_code: Provider::ErrorCode::GENERAL,
205
+ error_code: error_code,
177
206
  original_error: e
178
207
  )
179
208
  end
@@ -4,8 +4,8 @@ module OpenFeature
4
4
  module SDK
5
5
  # Used to combine evaluation contexts from different sources
6
6
  class EvaluationContextBuilder
7
- def call(api_context:, client_context:, invocation_context:)
8
- available_contexts = [api_context, client_context, invocation_context].compact
7
+ def call(api_context:, client_context:, invocation_context:, transaction_context: nil)
8
+ available_contexts = [api_context, transaction_context, client_context, invocation_context].compact
9
9
 
10
10
  return nil if available_contexts.empty?
11
11
 
@@ -18,6 +18,20 @@ module OpenFeature
18
18
  @logger = logger
19
19
  end
20
20
 
21
+ # Runs error hooks and finally hooks for a short-circuit evaluation
22
+ # (spec 1.7.6 + 1.7.7). Before hooks and after hooks are NOT run.
23
+ #
24
+ # @param ordered_hooks [Array] hooks in before-order
25
+ # @param hook_context [HookContext] the hook context
26
+ # @param hints [Hints] hook hints
27
+ # @param evaluation_details [EvaluationDetails] the short-circuit result
28
+ def run_short_circuit(ordered_hooks:, hook_context:, hints:, evaluation_details:)
29
+ error = StandardError.new(evaluation_details.error_code)
30
+ run_error_hooks(ordered_hooks, hook_context, error, hints)
31
+ ensure
32
+ run_finally_hooks(ordered_hooks, hook_context, evaluation_details, hints)
33
+ end
34
+
21
35
  # Executes the full hook lifecycle around the flag evaluation block.
22
36
  #
23
37
  # @param ordered_hooks [Array] hooks in before-order (API, Client, Invocation, Provider)
@@ -31,7 +45,15 @@ module OpenFeature
31
45
  begin
32
46
  run_before_hooks(ordered_hooks, hook_context, hints)
33
47
  evaluation_details = evaluate_block.call(hook_context)
34
- run_after_hooks(ordered_hooks, hook_context, evaluation_details, hints)
48
+
49
+ # Spec 4.3.6: If evaluation resulted in an error (e.g. FLAG_NOT_FOUND,
50
+ # TYPE_MISMATCH), run error hooks instead of after hooks.
51
+ if evaluation_details.error_code
52
+ error = StandardError.new(evaluation_details.error_message || evaluation_details.error_code)
53
+ run_error_hooks(ordered_hooks, hook_context, error, hints)
54
+ else
55
+ run_after_hooks(ordered_hooks, hook_context, evaluation_details, hints)
56
+ end
35
57
  rescue => e
36
58
  run_error_hooks(ordered_hooks, hook_context, e, hints)
37
59
 
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenFeature
4
+ module SDK
5
+ module Hooks
6
+ class LoggingHook
7
+ include Hook
8
+
9
+ def initialize(logger: nil, include_evaluation_context: false)
10
+ @logger = logger
11
+ @include_evaluation_context = include_evaluation_context
12
+ end
13
+
14
+ def before(hook_context:, hints:)
15
+ logger&.debug { build_before_message(hook_context) }
16
+ nil
17
+ end
18
+
19
+ def after(hook_context:, evaluation_details:, hints:)
20
+ logger&.debug { build_after_message(hook_context, evaluation_details) }
21
+ nil
22
+ end
23
+
24
+ def error(hook_context:, exception:, hints:)
25
+ logger&.error { build_error_message(hook_context, exception) }
26
+ nil
27
+ end
28
+
29
+ private
30
+
31
+ def logger
32
+ @logger || OpenFeature::SDK.configuration.logger
33
+ end
34
+
35
+ def build_before_message(hook_context)
36
+ parts = base_parts("before", hook_context)
37
+ parts << "evaluation_context=#{format_context(hook_context.evaluation_context)}" if @include_evaluation_context
38
+ parts.join(" ")
39
+ end
40
+
41
+ def build_after_message(hook_context, evaluation_details)
42
+ parts = base_parts("after", hook_context)
43
+ parts << "reason=#{sanitize(evaluation_details.reason)}" if evaluation_details.reason
44
+ parts << "variant=#{sanitize(evaluation_details.variant)}" if evaluation_details.variant
45
+ parts << "value=#{sanitize(evaluation_details.value)}"
46
+ parts << "evaluation_context=#{format_context(hook_context.evaluation_context)}" if @include_evaluation_context
47
+ parts.join(" ")
48
+ end
49
+
50
+ def build_error_message(hook_context, exception)
51
+ parts = base_parts("error", hook_context)
52
+ error_code = exception.respond_to?(:error_code) ? exception.error_code : Provider::ErrorCode::GENERAL
53
+ parts << "error_code=#{sanitize(error_code)}"
54
+ parts << "error_message=#{sanitize(exception.message)}"
55
+ parts << "evaluation_context=#{format_context(hook_context.evaluation_context)}" if @include_evaluation_context
56
+ parts.join(" ")
57
+ end
58
+
59
+ def base_parts(stage, hook_context)
60
+ domain = hook_context.client_metadata&.domain
61
+ provider_name = hook_context.provider_metadata&.name
62
+ [
63
+ "stage=#{stage}",
64
+ "domain=#{sanitize(domain)}",
65
+ "provider_name=#{sanitize(provider_name)}",
66
+ "flag_key=#{sanitize(hook_context.flag_key)}",
67
+ "default_value=#{sanitize(hook_context.default_value)}"
68
+ ]
69
+ end
70
+
71
+ def format_context(evaluation_context)
72
+ return "" unless evaluation_context
73
+ fields = evaluation_context.fields.dup
74
+ fields["targeting_key"] = evaluation_context.targeting_key if evaluation_context.targeting_key
75
+ fields.map { |k, v| "#{sanitize(k)}=#{sanitize(v)}" }.join(", ")
76
+ end
77
+
78
+ def sanitize(value)
79
+ value.to_s.gsub(/[\n\r]/, " ")
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -4,3 +4,4 @@ require_relative "hooks/hints"
4
4
  require_relative "hooks/hook"
5
5
  require_relative "hooks/hook_context"
6
6
  require_relative "hooks/hook_executor"
7
+ require_relative "hooks/logging_hook"
@@ -29,8 +29,10 @@ module OpenFeature
29
29
  end
30
30
 
31
31
  def update_flags(new_flags)
32
+ old_keys = @flags.keys
32
33
  @flags = new_flags.dup
33
- emit_provider_changed(new_flags.keys)
34
+ changed_keys = (old_keys | new_flags.keys)
35
+ emit_provider_changed(changed_keys)
34
36
  end
35
37
 
36
38
  def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenFeature
4
+ module SDK
5
+ class ThreadLocalTransactionContextPropagator
6
+ include TransactionContextPropagator
7
+
8
+ THREAD_KEY = :openfeature_transaction_context
9
+
10
+ def set_transaction_context(evaluation_context)
11
+ Thread.current[THREAD_KEY] = evaluation_context
12
+ end
13
+
14
+ def get_transaction_context
15
+ Thread.current[THREAD_KEY]
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenFeature
4
+ module SDK
5
+ module TransactionContextPropagator
6
+ def set_transaction_context(evaluation_context)
7
+ raise NotImplementedError
8
+ end
9
+
10
+ def get_transaction_context
11
+ raise NotImplementedError
12
+ end
13
+ end
14
+ end
15
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OpenFeature
4
4
  module SDK
5
- VERSION = "0.6.1"
5
+ VERSION = "0.6.3"
6
6
  end
7
7
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "sdk/version"
4
4
  require_relative "sdk/api"
5
+ require_relative "sdk/transaction_context_propagator"
6
+ require_relative "sdk/thread_local_transaction_context_propagator"
5
7
 
6
8
  module OpenFeature
7
9
  # TODO: Add documentation
data/renovate.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "extends": [
4
4
  "config:recommended",
5
5
  ":automergeStableNonMajor",
6
- "npm:unpublishSafe"
6
+ "security:minimumReleaseAgeNpm"
7
7
  ],
8
8
  "semanticCommits": "enabled"
9
9
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openfeature-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.6.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - OpenFeature Authors
@@ -159,6 +159,7 @@ files:
159
159
  - LICENSE
160
160
  - README.md
161
161
  - Rakefile
162
+ - cucumber.yml
162
163
  - lib/open_feature/sdk.rb
163
164
  - lib/open_feature/sdk/api.rb
164
165
  - lib/open_feature/sdk/client.rb
@@ -173,6 +174,7 @@ files:
173
174
  - lib/open_feature/sdk/hooks/hook.rb
174
175
  - lib/open_feature/sdk/hooks/hook_context.rb
175
176
  - lib/open_feature/sdk/hooks/hook_executor.rb
177
+ - lib/open_feature/sdk/hooks/logging_hook.rb
176
178
  - lib/open_feature/sdk/provider.rb
177
179
  - lib/open_feature/sdk/provider/error_code.rb
178
180
  - lib/open_feature/sdk/provider/event_emitter.rb
@@ -185,7 +187,9 @@ files:
185
187
  - lib/open_feature/sdk/provider_initialization_error.rb
186
188
  - lib/open_feature/sdk/provider_state.rb
187
189
  - lib/open_feature/sdk/provider_state_registry.rb
190
+ - lib/open_feature/sdk/thread_local_transaction_context_propagator.rb
188
191
  - lib/open_feature/sdk/tracking_event_details.rb
192
+ - lib/open_feature/sdk/transaction_context_propagator.rb
189
193
  - lib/open_feature/sdk/version.rb
190
194
  - release-please-config.json
191
195
  - renovate.json