openfeature-sdk 0.4.1 → 0.5.1
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/.release-please-manifest.json +1 -1
- data/.ruby-version +1 -1
- data/.tool-versions +1 -1
- data/CHANGELOG.md +18 -0
- data/CLAUDE.md +72 -0
- data/Gemfile.lock +4 -1
- data/LICENSE +1 -1
- data/README.md +44 -10
- data/lib/open_feature/sdk/api.rb +17 -0
- data/lib/open_feature/sdk/client.rb +59 -9
- data/lib/open_feature/sdk/configuration.rb +205 -41
- data/lib/open_feature/sdk/event_dispatcher.rb +80 -0
- data/lib/open_feature/sdk/hooks/hints.rb +2 -0
- data/lib/open_feature/sdk/hooks/hook.rb +34 -0
- data/lib/open_feature/sdk/hooks/hook_context.rb +29 -0
- data/lib/open_feature/sdk/hooks/hook_executor.rb +102 -0
- data/lib/open_feature/sdk/hooks.rb +6 -0
- data/lib/open_feature/sdk/provider/event_emitter.rb +37 -0
- data/lib/open_feature/sdk/provider/in_memory_provider.rb +1 -1
- data/lib/open_feature/sdk/provider.rb +11 -0
- data/lib/open_feature/sdk/provider_event.rb +25 -0
- data/lib/open_feature/sdk/provider_state.rb +27 -0
- data/lib/open_feature/sdk/provider_state_registry.rb +99 -0
- data/lib/open_feature/sdk/version.rb +1 -1
- metadata +26 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 36540a362c2f482ce84d2aa8ba3a6efd9b5d68fe7881ed4037f1422ad85f5525
|
|
4
|
+
data.tar.gz: 4f7e6a764074d50467949e056b0d50be794df0a67323bb80c5e34a724236314b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b77f6731c17b537069bbf72cf4760e88293bc59c9ae147079ff3b24ac6ac7f1b18b42b49e29fd1a04a85a6054d0a43834289561c06d6b342ba579ec23d510ef8
|
|
7
|
+
data.tar.gz: 077d8a3f20c396c82413dad70b9a93cff0cd27a76ff8034f67c50bfa0d08235e3424755a4cd33b81c39fed962d7a62e40d1f257d3bbc4e87bac787d0e315b3fb
|
data/.ruby-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.4.
|
|
1
|
+
3.4.8
|
data/.tool-versions
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
ruby 3.4.
|
|
1
|
+
ruby 3.4.8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.1](https://github.com/open-feature/ruby-sdk/compare/v0.5.0...v0.5.1) (2026-03-04)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* implement hooks lifecycle (spec section 4) ([#214](https://github.com/open-feature/ruby-sdk/issues/214)) ([41c3b9e](https://github.com/open-feature/ruby-sdk/commit/41c3b9e2b73b34f50d8135122fb4592f8ec44e49))
|
|
9
|
+
|
|
10
|
+
## [0.5.0](https://github.com/open-feature/ruby-sdk/compare/v0.4.1...v0.5.0) (2026-01-16)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### ⚠ BREAKING CHANGES
|
|
14
|
+
|
|
15
|
+
* add provider eventing, remove setProvider timeout ([#207](https://github.com/open-feature/ruby-sdk/issues/207))
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* add provider eventing, remove setProvider timeout ([#207](https://github.com/open-feature/ruby-sdk/issues/207)) ([fffd615](https://github.com/open-feature/ruby-sdk/commit/fffd6155ff308c75220185de030c38eb6eeac7e8))
|
|
20
|
+
|
|
3
21
|
## [0.4.1](https://github.com/open-feature/ruby-sdk/compare/v0.4.0...v0.4.1) (2025-11-03)
|
|
4
22
|
|
|
5
23
|
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project Overview
|
|
6
|
+
|
|
7
|
+
OpenFeature Ruby SDK — implements the [OpenFeature specification](https://openfeature.dev) (v0.8.0) for vendor-agnostic feature flag management. Published as the `openfeature-sdk` gem. Pure Ruby, no runtime dependencies. Requires Ruby >= 3.1.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Install dependencies
|
|
13
|
+
bundle install
|
|
14
|
+
|
|
15
|
+
# Run full test suite + linting (default rake task)
|
|
16
|
+
bundle exec rake
|
|
17
|
+
|
|
18
|
+
# Run tests only
|
|
19
|
+
bundle exec rspec
|
|
20
|
+
|
|
21
|
+
# Run a single test file
|
|
22
|
+
bundle exec rspec spec/open_feature/sdk/client_spec.rb
|
|
23
|
+
|
|
24
|
+
# Run a specific test by line number
|
|
25
|
+
bundle exec rspec spec/open_feature/sdk/client_spec.rb:40
|
|
26
|
+
|
|
27
|
+
# Lint (StandardRB with performance plugin)
|
|
28
|
+
bundle exec rake standard
|
|
29
|
+
|
|
30
|
+
# Auto-fix lint issues
|
|
31
|
+
bundle exec standardrb --fix
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Architecture
|
|
35
|
+
|
|
36
|
+
Entry point: `require 'open_feature/sdk'` — the `OpenFeature::SDK` module delegates all method calls to `API.instance` (Singleton) via `method_missing`.
|
|
37
|
+
|
|
38
|
+
### Core Components
|
|
39
|
+
|
|
40
|
+
- **API** (`lib/open_feature/sdk/api.rb`) — Singleton orchestrator. Manages providers (global or domain-scoped), builds clients, stores API-level evaluation context, and registers event handlers.
|
|
41
|
+
- **Configuration** (`lib/open_feature/sdk/configuration.rb`) — Thread-safe provider storage. Handles provider lifecycle (init/shutdown), domain-scoped provider mapping, and event dispatching. Uses Mutex for all shared state.
|
|
42
|
+
- **Client** (`lib/open_feature/sdk/client.rb`) — Flag evaluation interface. Uses `class_eval` metaprogramming to generate 12 typed methods: `fetch_{boolean,string,number,integer,float,object}_value` and `fetch_*_details` variants. Merges evaluation contexts (API + client + invocation).
|
|
43
|
+
- **EvaluationContext** (`lib/open_feature/sdk/evaluation_context.rb`) — Key-value targeting data with a special `targeting_key`. Supports merging with precedence: invocation > client > API.
|
|
44
|
+
|
|
45
|
+
### Provider System
|
|
46
|
+
|
|
47
|
+
- **Provider interface** — Must implement 6 `fetch_*_value` methods, optional `init(evaluation_context)` and `shutdown`. Returns `ResolutionDetails`.
|
|
48
|
+
- **EventEmitter** (`lib/open_feature/sdk/provider/event_emitter.rb`) — Mixin that providers include to emit lifecycle events.
|
|
49
|
+
- **Built-in providers**: `NoOpProvider` (default), `InMemoryProvider` (testing/examples).
|
|
50
|
+
- **Provider states**: `NOT_READY → READY`, with `ERROR`, `FATAL`, `STALE` transitions. Tracked per-instance via `ProviderStateRegistry` using `object_id`.
|
|
51
|
+
- **Initialization modes**: `set_provider` (async, background thread) or `set_provider_and_wait` (sync, raises `ProviderInitializationError` on failure).
|
|
52
|
+
|
|
53
|
+
### Event System
|
|
54
|
+
|
|
55
|
+
- **EventDispatcher** (`lib/open_feature/sdk/event_dispatcher.rb`) — Thread-safe pub-sub. Handlers called outside mutex to prevent deadlocks. Supports API-level and client-level handlers.
|
|
56
|
+
- **ProviderEvent** constants: `PROVIDER_READY`, `PROVIDER_ERROR`, `PROVIDER_STALE`, `PROVIDER_CONFIGURATION_CHANGED`.
|
|
57
|
+
|
|
58
|
+
## Test Structure
|
|
59
|
+
|
|
60
|
+
Tests in `spec/` split into two categories:
|
|
61
|
+
- `spec/specification/` — OpenFeature spec compliance tests, organized by requirement number (e.g., "Requirement 1.1.1")
|
|
62
|
+
- `spec/open_feature/` — Unit tests for individual components
|
|
63
|
+
|
|
64
|
+
Uses Timecop for time-sensitive tests (auto-reset after each test), SimpleCov for coverage.
|
|
65
|
+
|
|
66
|
+
## Conventions
|
|
67
|
+
|
|
68
|
+
- **Linter**: StandardRB (Ruby Standard Style) with `standard-performance` plugin, targeting Ruby 3.1
|
|
69
|
+
- **Commits**: Conventional Commits required for PR titles (enforced by CI)
|
|
70
|
+
- **Releases**: Automated via release-please; changelog auto-generated
|
|
71
|
+
- **Threading**: All shared mutable state must be Mutex-protected. Provider storage uses immutable reassignment (`@providers = @providers.dup.merge(...)`)
|
|
72
|
+
- **Structs for DTOs**: `EvaluationDetails`, `ResolutionDetails`, `ClientMetadata`, `ProviderMetadata` are `Struct`-based
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
openfeature-sdk (0.
|
|
4
|
+
openfeature-sdk (0.5.1)
|
|
5
5
|
|
|
6
6
|
GEM
|
|
7
7
|
remote: https://rubygems.org/
|
|
@@ -89,12 +89,14 @@ GEM
|
|
|
89
89
|
rubocop-performance (~> 1.20.2)
|
|
90
90
|
stringio (3.1.0)
|
|
91
91
|
strscan (3.1.0)
|
|
92
|
+
timecop (0.9.10)
|
|
92
93
|
unicode-display_width (2.5.0)
|
|
93
94
|
|
|
94
95
|
PLATFORMS
|
|
95
96
|
arm64-darwin-21
|
|
96
97
|
arm64-darwin-22
|
|
97
98
|
arm64-darwin-23
|
|
99
|
+
arm64-darwin-24
|
|
98
100
|
x64-mingw-ucrt
|
|
99
101
|
x64-mingw32
|
|
100
102
|
x86_64-darwin-19
|
|
@@ -112,6 +114,7 @@ DEPENDENCIES
|
|
|
112
114
|
simplecov-cobertura (~> 2.1.0)
|
|
113
115
|
standard
|
|
114
116
|
standard-performance
|
|
117
|
+
timecop (~> 0.9.10)
|
|
115
118
|
|
|
116
119
|
BUNDLED WITH
|
|
117
120
|
2.3.25
|
data/LICENSE
CHANGED
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
same "printed page" as the copyright notice for easier
|
|
187
187
|
identification within third-party archives.
|
|
188
188
|
|
|
189
|
-
Copyright
|
|
189
|
+
Copyright OpenFeature Maintainers
|
|
190
190
|
|
|
191
191
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
192
|
you may not use this file except in compliance with the License.
|
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.
|
|
21
|
-
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.
|
|
20
|
+
<a href="https://github.com/open-feature/ruby-sdk/releases/tag/v0.5.1">
|
|
21
|
+
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.5.1&color=blue&style=for-the-badge" />
|
|
22
22
|
</a>
|
|
23
23
|
|
|
24
24
|
<!-- x-release-please-end -->
|
|
@@ -104,7 +104,7 @@ object = client.fetch_object_value(flag_key: 'object_value', default_value: { na
|
|
|
104
104
|
| ⚠️ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
|
|
105
105
|
| ❌ | [Logging](#logging) | Integrate with popular logging packages. |
|
|
106
106
|
| ✅ | [Domains](#domains) | Logically bind clients with providers. |
|
|
107
|
-
|
|
|
107
|
+
| ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. |
|
|
108
108
|
| ⚠️ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
|
|
109
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. |
|
|
@@ -142,6 +142,7 @@ begin
|
|
|
142
142
|
rescue OpenFeature::SDK::ProviderInitializationError => e
|
|
143
143
|
puts "Provider failed to initialize: #{e.message}"
|
|
144
144
|
puts "Error code: #{e.error_code}"
|
|
145
|
+
# original_error contains the underlying exception that caused the initialization failure
|
|
145
146
|
puts "Original error: #{e.original_error}"
|
|
146
147
|
end
|
|
147
148
|
|
|
@@ -163,8 +164,9 @@ end
|
|
|
163
164
|
|
|
164
165
|
The `set_provider_and_wait` method:
|
|
165
166
|
- Waits for the provider's `init` method to complete successfully
|
|
166
|
-
- Raises `ProviderInitializationError`
|
|
167
|
-
- Provides access to the
|
|
167
|
+
- Raises `ProviderInitializationError` if initialization fails or times out
|
|
168
|
+
- Provides access to the provider instance, error code, and original exception for debugging
|
|
169
|
+
- The `original_error` field contains the underlying exception that caused the initialization failure
|
|
168
170
|
- Uses the same thread-safe provider switching as `set_provider`
|
|
169
171
|
|
|
170
172
|
In some situations, it may be beneficial to register multiple providers in the same application.
|
|
@@ -231,15 +233,47 @@ legacy_flag_client = OpenFeature::SDK.build_client(domain: "legacy_flags")
|
|
|
231
233
|
|
|
232
234
|
### Eventing
|
|
233
235
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
<!-- Events allow you to react to state changes in the provider or underlying flag management system, such as flag definition changes, provider readiness, or error conditions.
|
|
236
|
+
Events allow you to react to state changes in the provider or underlying flag management system, such as flag definition changes, provider readiness, or error conditions.
|
|
237
237
|
Initialization events (`PROVIDER_READY` on success, `PROVIDER_ERROR` on failure) are dispatched for every provider.
|
|
238
238
|
Some providers support additional events, such as `PROVIDER_CONFIGURATION_CHANGED`.
|
|
239
239
|
|
|
240
|
-
Please refer to the documentation of the provider you're using to see what events are supported.
|
|
240
|
+
Please refer to the documentation of the provider you're using to see what events are supported.
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
# Register event handlers at the API (global) level
|
|
244
|
+
ready_handler = ->(event_details) do
|
|
245
|
+
puts "Provider #{event_details[:provider].metadata.name} is ready!"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, ready_handler)
|
|
249
|
+
|
|
250
|
+
# The SDK automatically emits lifecycle events. Providers can emit additional spontaneous events
|
|
251
|
+
# using the EventEmitter mixin to signal internal state changes like configuration updates.
|
|
252
|
+
class MyEventAwareProvider
|
|
253
|
+
include OpenFeature::SDK::Provider::EventEmitter
|
|
241
254
|
|
|
242
|
-
|
|
255
|
+
def init(evaluation_context)
|
|
256
|
+
# Start background process to monitor for configuration changes
|
|
257
|
+
# Note: SDK automatically emits PROVIDER_READY when init completes successfully
|
|
258
|
+
start_background_process
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def start_background_process
|
|
262
|
+
Thread.new do
|
|
263
|
+
# Monitor for configuration changes and emit events when they occur
|
|
264
|
+
if configuration_changed?
|
|
265
|
+
emit_event(
|
|
266
|
+
OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED,
|
|
267
|
+
message: "Flag configuration updated"
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Remove specific handlers when no longer needed
|
|
275
|
+
OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, ready_handler)
|
|
276
|
+
```
|
|
243
277
|
|
|
244
278
|
### Shutdown
|
|
245
279
|
|
data/lib/open_feature/sdk/api.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "evaluation_context"
|
|
|
8
8
|
require_relative "evaluation_context_builder"
|
|
9
9
|
require_relative "evaluation_details"
|
|
10
10
|
require_relative "client_metadata"
|
|
11
|
+
require_relative "hooks"
|
|
11
12
|
require_relative "client"
|
|
12
13
|
require_relative "provider"
|
|
13
14
|
|
|
@@ -51,6 +52,22 @@ module OpenFeature
|
|
|
51
52
|
rescue
|
|
52
53
|
Client.new(provider: Provider::NoOpProvider.new, evaluation_context:)
|
|
53
54
|
end
|
|
55
|
+
|
|
56
|
+
def add_handler(event_type, handler)
|
|
57
|
+
configuration.add_handler(event_type, handler)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def remove_handler(event_type, handler)
|
|
61
|
+
configuration.remove_handler(event_type, handler)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def logger
|
|
65
|
+
configuration.logger
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def logger=(new_logger)
|
|
69
|
+
configuration.logger = new_logger
|
|
70
|
+
end
|
|
54
71
|
end
|
|
55
72
|
end
|
|
56
73
|
end
|
|
@@ -15,6 +15,7 @@ module OpenFeature
|
|
|
15
15
|
}.freeze
|
|
16
16
|
RESULT_TYPE = TYPE_CLASS_MAP.keys.freeze
|
|
17
17
|
SUFFIXES = %i[value details].freeze
|
|
18
|
+
EMPTY_HINTS = Hooks::Hints.new.freeze
|
|
18
19
|
|
|
19
20
|
attr_reader :metadata, :evaluation_context
|
|
20
21
|
|
|
@@ -27,14 +28,21 @@ module OpenFeature
|
|
|
27
28
|
@hooks = []
|
|
28
29
|
end
|
|
29
30
|
|
|
31
|
+
def add_handler(event_type, handler = nil, &block)
|
|
32
|
+
actual_handler = handler || block
|
|
33
|
+
OpenFeature::SDK.configuration.add_client_handler(self, event_type, actual_handler)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def remove_handler(event_type, handler = nil, &block)
|
|
37
|
+
actual_handler = handler || block
|
|
38
|
+
OpenFeature::SDK.configuration.remove_client_handler(self, event_type, actual_handler)
|
|
39
|
+
end
|
|
40
|
+
|
|
30
41
|
RESULT_TYPE.each do |result_type|
|
|
31
42
|
SUFFIXES.each do |suffix|
|
|
32
43
|
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
# end
|
|
36
|
-
def fetch_#{result_type}_#{suffix}(flag_key:, default_value:, evaluation_context: nil)
|
|
37
|
-
evaluation_details = fetch_details(type: :#{result_type}, flag_key:, default_value:, evaluation_context:)
|
|
44
|
+
def fetch_#{result_type}_#{suffix}(flag_key:, default_value:, evaluation_context: nil, hooks: [], hook_hints: nil)
|
|
45
|
+
evaluation_details = fetch_details(type: :#{result_type}, flag_key:, default_value:, evaluation_context:, invocation_hooks: hooks, hook_hints: hook_hints)
|
|
38
46
|
#{"evaluation_details.value" if suffix == :value}
|
|
39
47
|
end
|
|
40
48
|
RUBY
|
|
@@ -43,12 +51,54 @@ module OpenFeature
|
|
|
43
51
|
|
|
44
52
|
private
|
|
45
53
|
|
|
46
|
-
def fetch_details(type:, flag_key:, default_value:, evaluation_context: nil)
|
|
54
|
+
def fetch_details(type:, flag_key:, default_value:, evaluation_context: nil, invocation_hooks: [], hook_hints: nil)
|
|
47
55
|
validate_default_value_type(type, default_value)
|
|
48
56
|
|
|
49
|
-
built_context = EvaluationContextBuilder.new.call(
|
|
57
|
+
built_context = EvaluationContextBuilder.new.call(
|
|
58
|
+
api_context: OpenFeature::SDK.evaluation_context,
|
|
59
|
+
client_context: self.evaluation_context,
|
|
60
|
+
invocation_context: evaluation_context
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Assemble ordered hooks: API → Client → Invocation → Provider (spec 4.4.2)
|
|
64
|
+
provider_hooks = @provider.respond_to?(:hooks) ? Array(@provider.hooks) : []
|
|
65
|
+
ordered_hooks = [*OpenFeature::SDK.hooks, *@hooks, *invocation_hooks, *provider_hooks]
|
|
66
|
+
|
|
67
|
+
# Fast path: skip hook ceremony when no hooks are registered
|
|
68
|
+
if ordered_hooks.empty?
|
|
69
|
+
return evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: built_context)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
hook_context = Hooks::HookContext.new(
|
|
73
|
+
flag_key: flag_key,
|
|
74
|
+
flag_value_type: type,
|
|
75
|
+
default_value: default_value,
|
|
76
|
+
evaluation_context: built_context,
|
|
77
|
+
client_metadata: @metadata,
|
|
78
|
+
provider_metadata: @provider.respond_to?(:metadata) ? @provider.metadata : nil
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
hints = if hook_hints.is_a?(Hooks::Hints)
|
|
82
|
+
hook_hints
|
|
83
|
+
elsif hook_hints
|
|
84
|
+
Hooks::Hints.new(hook_hints)
|
|
85
|
+
else
|
|
86
|
+
EMPTY_HINTS
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
executor = Hooks::HookExecutor.new(logger: OpenFeature::SDK.configuration.logger)
|
|
90
|
+
executor.execute(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints) do |hctx|
|
|
91
|
+
evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
50
94
|
|
|
51
|
-
|
|
95
|
+
def evaluate_flag(type:, flag_key:, default_value:, evaluation_context:)
|
|
96
|
+
resolution_details = @provider.send(
|
|
97
|
+
:"fetch_#{type}_value",
|
|
98
|
+
flag_key: flag_key,
|
|
99
|
+
default_value: default_value,
|
|
100
|
+
evaluation_context: evaluation_context
|
|
101
|
+
)
|
|
52
102
|
|
|
53
103
|
if TYPE_CLASS_MAP[type].none? { |klass| resolution_details.value.is_a?(klass) }
|
|
54
104
|
resolution_details.value = default_value
|
|
@@ -56,7 +106,7 @@ module OpenFeature
|
|
|
56
106
|
resolution_details.reason = Provider::Reason::ERROR
|
|
57
107
|
end
|
|
58
108
|
|
|
59
|
-
EvaluationDetails.new(flag_key
|
|
109
|
+
EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution_details)
|
|
60
110
|
end
|
|
61
111
|
|
|
62
112
|
def validate_default_value_type(type, default_value)
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
require "timeout"
|
|
4
4
|
require_relative "api"
|
|
5
5
|
require_relative "provider_initialization_error"
|
|
6
|
+
require_relative "event_dispatcher"
|
|
7
|
+
require_relative "provider_event"
|
|
8
|
+
require_relative "provider_state_registry"
|
|
9
|
+
require_relative "provider/event_emitter"
|
|
6
10
|
|
|
7
11
|
module OpenFeature
|
|
8
12
|
module SDK
|
|
@@ -14,73 +18,233 @@ module OpenFeature
|
|
|
14
18
|
extend Forwardable
|
|
15
19
|
|
|
16
20
|
attr_accessor :evaluation_context, :hooks
|
|
21
|
+
attr_reader :logger
|
|
17
22
|
|
|
18
23
|
def initialize
|
|
19
24
|
@hooks = []
|
|
20
25
|
@providers = {}
|
|
21
26
|
@provider_mutex = Mutex.new
|
|
27
|
+
@logger = nil
|
|
28
|
+
@event_dispatcher = EventDispatcher.new(@logger)
|
|
29
|
+
@provider_state_registry = ProviderStateRegistry.new
|
|
30
|
+
@client_handlers = {}
|
|
31
|
+
@client_handlers_mutex = Mutex.new
|
|
22
32
|
end
|
|
23
33
|
|
|
24
34
|
def provider(domain: nil)
|
|
25
35
|
@providers[domain] || @providers[nil]
|
|
26
36
|
end
|
|
27
37
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
38
|
+
def logger=(new_logger)
|
|
39
|
+
@logger = new_logger
|
|
40
|
+
@event_dispatcher.logger = new_logger if @event_dispatcher
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def add_handler(event_type, handler)
|
|
44
|
+
@event_dispatcher.add_handler(event_type, handler)
|
|
45
|
+
run_immediate_handler(event_type, handler, nil)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def remove_handler(event_type, handler)
|
|
49
|
+
@event_dispatcher.remove_handler(event_type, handler)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @api private
|
|
53
|
+
def add_client_handler(client, event_type, handler)
|
|
54
|
+
@client_handlers_mutex.synchronize do
|
|
55
|
+
@client_handlers[client] ||= Hash.new { |h, k| h[k] = [] }
|
|
56
|
+
@client_handlers[client][event_type] << handler
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
run_immediate_handler(event_type, handler, client)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @api private
|
|
63
|
+
def remove_client_handler(client, event_type, handler)
|
|
64
|
+
@client_handlers_mutex.synchronize do
|
|
65
|
+
return unless @client_handlers[client]
|
|
66
|
+
handlers = @client_handlers[client][event_type]
|
|
67
|
+
handlers&.delete(handler)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
32
71
|
def set_provider(provider, domain: nil)
|
|
72
|
+
set_provider_internal(provider, domain: domain, wait_for_init: false)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def set_provider_and_wait(provider, domain: nil)
|
|
76
|
+
set_provider_internal(provider, domain: domain, wait_for_init: true)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def reset
|
|
82
|
+
@event_dispatcher.clear_all_handlers
|
|
83
|
+
@client_handlers_mutex.synchronize do
|
|
84
|
+
@client_handlers.clear
|
|
85
|
+
end
|
|
86
|
+
@provider_state_registry.clear
|
|
33
87
|
@provider_mutex.synchronize do
|
|
34
|
-
@providers
|
|
35
|
-
provider.init if provider.respond_to?(:init)
|
|
36
|
-
new_providers = @providers.dup
|
|
37
|
-
new_providers[domain] = provider
|
|
38
|
-
@providers = new_providers
|
|
88
|
+
@providers.clear
|
|
39
89
|
end
|
|
40
90
|
end
|
|
41
91
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
# @raise [ProviderInitializationError] if the provider fails to initialize or times out
|
|
49
|
-
def set_provider_and_wait(provider, domain: nil, timeout: 30)
|
|
92
|
+
def set_provider_internal(provider, domain:, wait_for_init:)
|
|
93
|
+
# Capture evaluation context before acquiring mutex to prevent race conditions
|
|
94
|
+
context_for_init = @evaluation_context
|
|
95
|
+
|
|
96
|
+
old_provider, provider_to_init = nil
|
|
97
|
+
|
|
50
98
|
@provider_mutex.synchronize do
|
|
51
99
|
old_provider = @providers[domain]
|
|
52
100
|
|
|
53
|
-
#
|
|
101
|
+
# Remove old provider state to prevent memory leaks
|
|
102
|
+
@provider_state_registry.remove_provider(old_provider)
|
|
103
|
+
|
|
104
|
+
new_providers = @providers.dup
|
|
105
|
+
new_providers[domain] = provider
|
|
106
|
+
@providers = new_providers
|
|
107
|
+
|
|
108
|
+
@provider_state_registry.set_initial_state(provider)
|
|
109
|
+
|
|
110
|
+
provider.send(:attach, self) if provider.is_a?(Provider::EventEmitter)
|
|
111
|
+
|
|
112
|
+
provider_to_init = provider
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Shutdown old provider outside mutex to avoid blocking other operations
|
|
116
|
+
# Only shutdown if it's a different provider to prevent race condition
|
|
117
|
+
if old_provider && old_provider != provider
|
|
54
118
|
begin
|
|
55
119
|
old_provider.shutdown if old_provider.respond_to?(:shutdown)
|
|
56
|
-
rescue
|
|
57
|
-
|
|
120
|
+
rescue => e
|
|
121
|
+
@logger&.warn("Error shutting down previous provider #{old_provider&.class&.name || "unknown"}: #{e.message}")
|
|
58
122
|
end
|
|
123
|
+
end
|
|
59
124
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
125
|
+
# Initialize provider outside the mutex to avoid blocking other operations
|
|
126
|
+
if wait_for_init
|
|
127
|
+
init_provider(provider_to_init, context_for_init, raise_on_error: true)
|
|
128
|
+
else
|
|
129
|
+
Thread.new do
|
|
130
|
+
init_provider(provider_to_init, context_for_init, raise_on_error: false)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def init_provider(provider, context, raise_on_error: false)
|
|
136
|
+
if provider.respond_to?(:init)
|
|
137
|
+
init_method = provider.method(:init)
|
|
138
|
+
if init_method.parameters.empty?
|
|
139
|
+
provider.init
|
|
140
|
+
else
|
|
141
|
+
provider.init(context)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY)
|
|
146
|
+
rescue => e
|
|
147
|
+
dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR,
|
|
148
|
+
error_code: Provider::ErrorCode::GENERAL,
|
|
149
|
+
message: e.message)
|
|
150
|
+
|
|
151
|
+
if raise_on_error
|
|
152
|
+
# Re-raise as ProviderInitializationError for synchronous callers
|
|
153
|
+
raise ProviderInitializationError.new(
|
|
154
|
+
"Provider #{provider.class.name} initialization failed: #{e.message}",
|
|
155
|
+
provider:,
|
|
156
|
+
error_code: Provider::ErrorCode::GENERAL,
|
|
157
|
+
original_error: e
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def dispatch_provider_event(provider, event_type, details = {})
|
|
163
|
+
@provider_state_registry.update_state_from_event(provider, event_type, details)
|
|
164
|
+
|
|
165
|
+
provider_name = extract_provider_name(provider)
|
|
166
|
+
|
|
167
|
+
event_details = {
|
|
168
|
+
provider_name: provider_name
|
|
169
|
+
}.merge(details)
|
|
170
|
+
|
|
171
|
+
run_handlers_for_provider(provider, event_type, event_details)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def provider_state(provider)
|
|
175
|
+
@provider_state_registry.get_state(provider)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
def extract_provider_name(provider)
|
|
181
|
+
provider.respond_to?(:metadata) ? provider.metadata.name : provider.class.name
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def run_handlers_for_provider(provider, event_type, event_details)
|
|
185
|
+
# Run global handlers (API-level, no domain filtering)
|
|
186
|
+
@event_dispatcher.trigger_event(event_type, event_details)
|
|
187
|
+
|
|
188
|
+
# Run client handlers (domain-scoped)
|
|
189
|
+
@client_handlers_mutex.synchronize do
|
|
190
|
+
@client_handlers.each do |client, handlers_by_event|
|
|
191
|
+
# Check if this client should receive events from this provider
|
|
192
|
+
client_provider = provider(domain: client.metadata.domain)
|
|
193
|
+
next unless client_provider&.equal?(provider)
|
|
194
|
+
|
|
195
|
+
# Trigger handlers for this client
|
|
196
|
+
handlers = handlers_by_event[event_type]
|
|
197
|
+
handlers.each do |handler|
|
|
198
|
+
handler.call(event_details)
|
|
199
|
+
rescue => e
|
|
200
|
+
@logger&.error("Client event handler failed: #{e.message}")
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def run_immediate_handler(event_type, handler, client)
|
|
207
|
+
status_to_event = {
|
|
208
|
+
ProviderState::READY => ProviderEvent::PROVIDER_READY,
|
|
209
|
+
ProviderState::ERROR => ProviderEvent::PROVIDER_ERROR,
|
|
210
|
+
ProviderState::FATAL => ProviderEvent::PROVIDER_ERROR,
|
|
211
|
+
ProviderState::STALE => ProviderEvent::PROVIDER_STALE
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if client.nil?
|
|
215
|
+
# API-level handler: check all providers
|
|
216
|
+
@providers.each do |domain, provider|
|
|
217
|
+
next unless provider
|
|
218
|
+
|
|
219
|
+
provider_state = @provider_state_registry.get_state(provider)
|
|
220
|
+
|
|
221
|
+
if event_type == status_to_event[provider_state]
|
|
222
|
+
provider_name = extract_provider_name(provider)
|
|
223
|
+
event_details = {provider_name: provider_name}
|
|
224
|
+
|
|
225
|
+
begin
|
|
226
|
+
handler.call(event_details)
|
|
227
|
+
rescue => e
|
|
228
|
+
@logger&.error("Immediate API event handler failed: #{e.message}")
|
|
65
229
|
end
|
|
66
230
|
end
|
|
231
|
+
end
|
|
232
|
+
else
|
|
233
|
+
# Client-level handler: check specific provider only
|
|
234
|
+
client_provider = provider(domain: client.metadata.domain)
|
|
235
|
+
return unless client_provider
|
|
67
236
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
raise ProviderInitializationError.new(
|
|
80
|
-
"Provider initialization failed: #{e.message}",
|
|
81
|
-
provider:,
|
|
82
|
-
original_error: e
|
|
83
|
-
)
|
|
237
|
+
provider_state = @provider_state_registry.get_state(client_provider)
|
|
238
|
+
|
|
239
|
+
if event_type == status_to_event[provider_state]
|
|
240
|
+
provider_name = extract_provider_name(client_provider)
|
|
241
|
+
event_details = {provider_name: provider_name}
|
|
242
|
+
|
|
243
|
+
begin
|
|
244
|
+
handler.call(event_details)
|
|
245
|
+
rescue => e
|
|
246
|
+
@logger&.error("Immediate client event handler failed: #{e.message}")
|
|
247
|
+
end
|
|
84
248
|
end
|
|
85
249
|
end
|
|
86
250
|
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "provider_event"
|
|
4
|
+
|
|
5
|
+
module OpenFeature
|
|
6
|
+
module SDK
|
|
7
|
+
# Thread-safe pub-sub for provider events
|
|
8
|
+
class EventDispatcher
|
|
9
|
+
attr_writer :logger
|
|
10
|
+
|
|
11
|
+
def initialize(logger = nil)
|
|
12
|
+
@handlers = {}
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@logger = logger
|
|
15
|
+
ProviderEvent::ALL_EVENTS.each { |event| @handlers[event] = [] }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add_handler(event_type, handler)
|
|
19
|
+
raise ArgumentError, "Invalid event type: #{event_type}" unless valid_event?(event_type)
|
|
20
|
+
raise ArgumentError, "Handler must respond to call" unless handler.respond_to?(:call)
|
|
21
|
+
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
@handlers[event_type] << handler
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def remove_handler(event_type, handler)
|
|
28
|
+
return unless valid_event?(event_type)
|
|
29
|
+
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
@handlers[event_type].delete(handler)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def remove_all_handlers(event_type)
|
|
36
|
+
return unless valid_event?(event_type)
|
|
37
|
+
|
|
38
|
+
@mutex.synchronize do
|
|
39
|
+
@handlers[event_type].clear
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def trigger_event(event_type, event_details = {})
|
|
44
|
+
return unless valid_event?(event_type)
|
|
45
|
+
|
|
46
|
+
handlers_to_call = nil
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
handlers_to_call = @handlers[event_type].dup
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Call handlers outside of mutex to avoid deadlocks
|
|
52
|
+
handlers_to_call.each do |handler|
|
|
53
|
+
handler.call(event_details)
|
|
54
|
+
rescue => e
|
|
55
|
+
@logger&.warn "Event handler failed for #{event_type}: #{e.message}\n#{e.backtrace.join("\n")}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def handler_count(event_type)
|
|
60
|
+
return 0 unless valid_event?(event_type)
|
|
61
|
+
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
@handlers[event_type].size
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def clear_all_handlers
|
|
68
|
+
@mutex.synchronize do
|
|
69
|
+
@handlers.each_value(&:clear)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def valid_event?(event_type)
|
|
76
|
+
ProviderEvent::ALL_EVENTS.include?(event_type)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenFeature
|
|
4
|
+
module SDK
|
|
5
|
+
module Hooks
|
|
6
|
+
# Module that hooks include. Provides default no-op implementations
|
|
7
|
+
# for all four lifecycle stages. A hook overrides the stages it cares about.
|
|
8
|
+
#
|
|
9
|
+
# Spec 4.3.1: Hooks MUST specify at least one stage.
|
|
10
|
+
module Hook
|
|
11
|
+
# Called before flag evaluation. May return an EvaluationContext
|
|
12
|
+
# that gets merged into the existing context (spec 4.3.2.1, 4.3.4, 4.3.5).
|
|
13
|
+
def before(hook_context:, hints:)
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Called after successful flag evaluation (spec 4.3.3).
|
|
18
|
+
def after(hook_context:, evaluation_details:, hints:)
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Called when an error occurs during flag evaluation (spec 4.3.6).
|
|
23
|
+
def error(hook_context:, exception:, hints:)
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Called unconditionally after flag evaluation (spec 4.3.7).
|
|
28
|
+
def finally(hook_context:, evaluation_details:, hints:)
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenFeature
|
|
4
|
+
module SDK
|
|
5
|
+
module Hooks
|
|
6
|
+
# Provides context to hook stages during flag evaluation.
|
|
7
|
+
#
|
|
8
|
+
# Per spec 4.1.1-4.1.5:
|
|
9
|
+
# - flag_key, flag_value_type, default_value are immutable (4.1.3)
|
|
10
|
+
# - client_metadata, provider_metadata are optional (4.1.2)
|
|
11
|
+
# - evaluation_context is mutable (for before hooks to modify, 4.1.4.1)
|
|
12
|
+
class HookContext
|
|
13
|
+
attr_reader :flag_key, :flag_value_type, :default_value,
|
|
14
|
+
:client_metadata, :provider_metadata
|
|
15
|
+
attr_accessor :evaluation_context
|
|
16
|
+
|
|
17
|
+
def initialize(flag_key:, flag_value_type:, default_value:, evaluation_context:,
|
|
18
|
+
client_metadata: nil, provider_metadata: nil)
|
|
19
|
+
@flag_key = flag_key.freeze
|
|
20
|
+
@flag_value_type = flag_value_type.freeze
|
|
21
|
+
@default_value = default_value.freeze
|
|
22
|
+
@evaluation_context = evaluation_context
|
|
23
|
+
@client_metadata = client_metadata
|
|
24
|
+
@provider_metadata = provider_metadata
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenFeature
|
|
4
|
+
module SDK
|
|
5
|
+
module Hooks
|
|
6
|
+
# Orchestrates the full hook lifecycle for flag evaluation.
|
|
7
|
+
#
|
|
8
|
+
# Hook execution order (spec 4.4.2):
|
|
9
|
+
# Before: API → Client → Invocation → Provider
|
|
10
|
+
# After/Error/Finally: Provider → Invocation → Client → API (reverse)
|
|
11
|
+
#
|
|
12
|
+
# Error handling (spec 4.4.3-4.4.7):
|
|
13
|
+
# - Before/after hook error → stop remaining hooks, run error hooks, return default
|
|
14
|
+
# - Error hook error → log, continue remaining error hooks
|
|
15
|
+
# - Finally hook error → log, continue remaining finally hooks
|
|
16
|
+
class HookExecutor
|
|
17
|
+
def initialize(logger: nil)
|
|
18
|
+
@logger = logger
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Executes the full hook lifecycle around the flag evaluation block.
|
|
22
|
+
#
|
|
23
|
+
# @param ordered_hooks [Array] hooks in before-order (API, Client, Invocation, Provider)
|
|
24
|
+
# @param hook_context [HookContext] the hook context
|
|
25
|
+
# @param hints [Hints] hook hints
|
|
26
|
+
# @param evaluate_block [Proc] the flag evaluation to wrap
|
|
27
|
+
# @return [EvaluationDetails] the evaluation result
|
|
28
|
+
def execute(ordered_hooks:, hook_context:, hints:, &evaluate_block)
|
|
29
|
+
evaluation_details = nil
|
|
30
|
+
|
|
31
|
+
begin
|
|
32
|
+
run_before_hooks(ordered_hooks, hook_context, hints)
|
|
33
|
+
evaluation_details = evaluate_block.call(hook_context)
|
|
34
|
+
run_after_hooks(ordered_hooks, hook_context, evaluation_details, hints)
|
|
35
|
+
rescue => e
|
|
36
|
+
run_error_hooks(ordered_hooks, hook_context, e, hints)
|
|
37
|
+
|
|
38
|
+
evaluation_details = EvaluationDetails.new(
|
|
39
|
+
flag_key: hook_context.flag_key,
|
|
40
|
+
resolution_details: Provider::ResolutionDetails.new(
|
|
41
|
+
value: hook_context.default_value,
|
|
42
|
+
error_code: Provider::ErrorCode::GENERAL,
|
|
43
|
+
reason: Provider::Reason::ERROR,
|
|
44
|
+
error_message: e.message
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
ensure
|
|
48
|
+
run_finally_hooks(ordered_hooks, hook_context, evaluation_details, hints)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
evaluation_details
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Spec 4.4.2: Before hooks run in order: API → Client → Invocation → Provider
|
|
57
|
+
# Spec 4.3.4/4.3.5: If a before hook returns an EvaluationContext, it is merged
|
|
58
|
+
# into the existing context for subsequent hooks and evaluation.
|
|
59
|
+
def run_before_hooks(hooks, hook_context, hints)
|
|
60
|
+
hooks.each do |hook|
|
|
61
|
+
next unless hook.respond_to?(:before)
|
|
62
|
+
result = hook.before(hook_context: hook_context, hints: hints)
|
|
63
|
+
if result.is_a?(EvaluationContext)
|
|
64
|
+
existing = hook_context.evaluation_context
|
|
65
|
+
hook_context.evaluation_context = existing ? existing.merge(result) : result
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Spec 4.4.2: After hooks run in reverse order: Provider → Invocation → Client → API
|
|
71
|
+
def run_after_hooks(hooks, hook_context, evaluation_details, hints)
|
|
72
|
+
hooks.reverse_each do |hook|
|
|
73
|
+
next unless hook.respond_to?(:after)
|
|
74
|
+
hook.after(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Spec 4.4.4: Error hooks run in reverse order.
|
|
79
|
+
# If an error hook itself errors, log and continue remaining error hooks.
|
|
80
|
+
def run_error_hooks(hooks, hook_context, exception, hints)
|
|
81
|
+
hooks.reverse_each do |hook|
|
|
82
|
+
next unless hook.respond_to?(:error)
|
|
83
|
+
hook.error(hook_context: hook_context, exception: exception, hints: hints)
|
|
84
|
+
rescue => e
|
|
85
|
+
@logger&.error("Error hook #{hook.class.name} failed: #{e.message}")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Spec 4.4.3: Finally hooks run in reverse order unconditionally.
|
|
90
|
+
# If a finally hook errors, log and continue remaining finally hooks.
|
|
91
|
+
def run_finally_hooks(hooks, hook_context, evaluation_details, hints)
|
|
92
|
+
hooks.reverse_each do |hook|
|
|
93
|
+
next unless hook.respond_to?(:finally)
|
|
94
|
+
hook.finally(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)
|
|
95
|
+
rescue => e
|
|
96
|
+
@logger&.error("Finally hook #{hook.class.name} failed: #{e.message}")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../provider_event"
|
|
4
|
+
|
|
5
|
+
module OpenFeature
|
|
6
|
+
module SDK
|
|
7
|
+
module Provider
|
|
8
|
+
# Mixin for providers that emit lifecycle events
|
|
9
|
+
module EventEmitter
|
|
10
|
+
def emit_event(event_type, details = {})
|
|
11
|
+
config = @configuration
|
|
12
|
+
return unless config
|
|
13
|
+
|
|
14
|
+
unless ::OpenFeature::SDK::ProviderEvent::ALL_EVENTS.include?(event_type)
|
|
15
|
+
raise ArgumentError, "Invalid event type: #{event_type}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
config.send(:dispatch_provider_event, self, event_type, details)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def configuration_attached?
|
|
22
|
+
!@configuration.nil?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def attach(configuration)
|
|
28
|
+
@configuration = configuration
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def detach
|
|
32
|
+
@configuration = nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -2,9 +2,20 @@ require_relative "provider/error_code"
|
|
|
2
2
|
require_relative "provider/reason"
|
|
3
3
|
require_relative "provider/resolution_details"
|
|
4
4
|
require_relative "provider/provider_metadata"
|
|
5
|
+
|
|
6
|
+
# Provider interfaces
|
|
7
|
+
require_relative "provider/event_emitter"
|
|
8
|
+
|
|
9
|
+
# Provider implementations
|
|
5
10
|
require_relative "provider/no_op_provider"
|
|
6
11
|
require_relative "provider/in_memory_provider"
|
|
7
12
|
|
|
13
|
+
# Event system components
|
|
14
|
+
require_relative "provider_event"
|
|
15
|
+
require_relative "provider_state"
|
|
16
|
+
require_relative "event_dispatcher"
|
|
17
|
+
require_relative "provider_state_registry"
|
|
18
|
+
|
|
8
19
|
module OpenFeature
|
|
9
20
|
module SDK
|
|
10
21
|
module Provider
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenFeature
|
|
4
|
+
module SDK
|
|
5
|
+
# Provider Event Types
|
|
6
|
+
#
|
|
7
|
+
# Defines the standard event types that providers can emit during their lifecycle.
|
|
8
|
+
# These events correspond to the OpenFeature specification events:
|
|
9
|
+
# https://openfeature.dev/specification/sections/events/
|
|
10
|
+
#
|
|
11
|
+
module ProviderEvent
|
|
12
|
+
PROVIDER_READY = "PROVIDER_READY"
|
|
13
|
+
PROVIDER_ERROR = "PROVIDER_ERROR"
|
|
14
|
+
PROVIDER_CONFIGURATION_CHANGED = "PROVIDER_CONFIGURATION_CHANGED"
|
|
15
|
+
PROVIDER_STALE = "PROVIDER_STALE"
|
|
16
|
+
|
|
17
|
+
ALL_EVENTS = [
|
|
18
|
+
PROVIDER_READY,
|
|
19
|
+
PROVIDER_ERROR,
|
|
20
|
+
PROVIDER_CONFIGURATION_CHANGED,
|
|
21
|
+
PROVIDER_STALE
|
|
22
|
+
].freeze
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenFeature
|
|
4
|
+
module SDK
|
|
5
|
+
# Provider State Types
|
|
6
|
+
#
|
|
7
|
+
# Defines the standard states that providers can be in during their lifecycle.
|
|
8
|
+
# These states correspond to the OpenFeature specification provider states:
|
|
9
|
+
# https://openfeature.dev/specification/types#provider-status
|
|
10
|
+
#
|
|
11
|
+
module ProviderState
|
|
12
|
+
NOT_READY = "NOT_READY"
|
|
13
|
+
READY = "READY"
|
|
14
|
+
ERROR = "ERROR"
|
|
15
|
+
STALE = "STALE"
|
|
16
|
+
FATAL = "FATAL"
|
|
17
|
+
|
|
18
|
+
ALL_STATES = [
|
|
19
|
+
NOT_READY,
|
|
20
|
+
READY,
|
|
21
|
+
ERROR,
|
|
22
|
+
STALE,
|
|
23
|
+
FATAL
|
|
24
|
+
].freeze
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "provider_state"
|
|
4
|
+
require_relative "provider_event"
|
|
5
|
+
require_relative "provider/error_code"
|
|
6
|
+
|
|
7
|
+
module OpenFeature
|
|
8
|
+
module SDK
|
|
9
|
+
# Tracks provider states
|
|
10
|
+
class ProviderStateRegistry
|
|
11
|
+
def initialize
|
|
12
|
+
@states = {}
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def set_initial_state(provider, state = ProviderState::NOT_READY)
|
|
17
|
+
return unless provider
|
|
18
|
+
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
@states[provider.object_id] = state
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def update_state_from_event(provider, event_type, event_details = nil)
|
|
25
|
+
return ProviderState::NOT_READY unless provider
|
|
26
|
+
|
|
27
|
+
new_state = state_from_event(event_type, event_details)
|
|
28
|
+
|
|
29
|
+
# Only update state if the event should cause a state change
|
|
30
|
+
if new_state
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
@states[provider.object_id] = new_state
|
|
33
|
+
end
|
|
34
|
+
new_state
|
|
35
|
+
else
|
|
36
|
+
# Return current state without changing it
|
|
37
|
+
get_state(provider)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def get_state(provider)
|
|
42
|
+
return ProviderState::NOT_READY unless provider
|
|
43
|
+
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
@states[provider.object_id] || ProviderState::NOT_READY
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def remove_provider(provider)
|
|
50
|
+
return unless provider
|
|
51
|
+
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
@states.delete(provider.object_id)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def ready?(provider)
|
|
58
|
+
get_state(provider) == ProviderState::READY
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def error?(provider)
|
|
62
|
+
state = get_state(provider)
|
|
63
|
+
[ProviderState::ERROR, ProviderState::FATAL].include?(state)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def clear
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
@states.clear
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def state_from_event(event_type, event_details = nil)
|
|
75
|
+
case event_type
|
|
76
|
+
when ProviderEvent::PROVIDER_READY
|
|
77
|
+
ProviderState::READY
|
|
78
|
+
when ProviderEvent::PROVIDER_STALE
|
|
79
|
+
ProviderState::STALE
|
|
80
|
+
when ProviderEvent::PROVIDER_ERROR
|
|
81
|
+
state_from_error_event(event_details)
|
|
82
|
+
when ProviderEvent::PROVIDER_CONFIGURATION_CHANGED
|
|
83
|
+
nil # No state change per OpenFeature spec Requirement 5.3.5
|
|
84
|
+
else
|
|
85
|
+
nil # No state change for unknown events - conservative default
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def state_from_error_event(event_details)
|
|
90
|
+
error_code = event_details&.dig(:error_code)
|
|
91
|
+
if error_code == Provider::ErrorCode::PROVIDER_FATAL
|
|
92
|
+
ProviderState::FATAL
|
|
93
|
+
else
|
|
94
|
+
ProviderState::ERROR
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
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.
|
|
4
|
+
version: 0.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- OpenFeature Authors
|
|
@@ -121,6 +121,20 @@ dependencies:
|
|
|
121
121
|
- - "~>"
|
|
122
122
|
- !ruby/object:Gem::Version
|
|
123
123
|
version: 2.1.0
|
|
124
|
+
- !ruby/object:Gem::Dependency
|
|
125
|
+
name: timecop
|
|
126
|
+
requirement: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - "~>"
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: 0.9.10
|
|
131
|
+
type: :development
|
|
132
|
+
prerelease: false
|
|
133
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - "~>"
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: 0.9.10
|
|
124
138
|
description: Ruby SDK for an the specifications for the open standard of feature flag
|
|
125
139
|
management
|
|
126
140
|
email:
|
|
@@ -136,6 +150,7 @@ files:
|
|
|
136
150
|
- ".standard.yml"
|
|
137
151
|
- ".tool-versions"
|
|
138
152
|
- CHANGELOG.md
|
|
153
|
+
- CLAUDE.md
|
|
139
154
|
- CODEOWNERS
|
|
140
155
|
- CODE_OF_CONDUCT.md
|
|
141
156
|
- CONTRIBUTING.md
|
|
@@ -152,21 +167,30 @@ files:
|
|
|
152
167
|
- lib/open_feature/sdk/evaluation_context.rb
|
|
153
168
|
- lib/open_feature/sdk/evaluation_context_builder.rb
|
|
154
169
|
- lib/open_feature/sdk/evaluation_details.rb
|
|
170
|
+
- lib/open_feature/sdk/event_dispatcher.rb
|
|
171
|
+
- lib/open_feature/sdk/hooks.rb
|
|
155
172
|
- lib/open_feature/sdk/hooks/hints.rb
|
|
173
|
+
- lib/open_feature/sdk/hooks/hook.rb
|
|
174
|
+
- lib/open_feature/sdk/hooks/hook_context.rb
|
|
175
|
+
- lib/open_feature/sdk/hooks/hook_executor.rb
|
|
156
176
|
- lib/open_feature/sdk/provider.rb
|
|
157
177
|
- lib/open_feature/sdk/provider/error_code.rb
|
|
178
|
+
- lib/open_feature/sdk/provider/event_emitter.rb
|
|
158
179
|
- lib/open_feature/sdk/provider/in_memory_provider.rb
|
|
159
180
|
- lib/open_feature/sdk/provider/no_op_provider.rb
|
|
160
181
|
- lib/open_feature/sdk/provider/provider_metadata.rb
|
|
161
182
|
- lib/open_feature/sdk/provider/reason.rb
|
|
162
183
|
- lib/open_feature/sdk/provider/resolution_details.rb
|
|
184
|
+
- lib/open_feature/sdk/provider_event.rb
|
|
163
185
|
- lib/open_feature/sdk/provider_initialization_error.rb
|
|
186
|
+
- lib/open_feature/sdk/provider_state.rb
|
|
187
|
+
- lib/open_feature/sdk/provider_state_registry.rb
|
|
164
188
|
- lib/open_feature/sdk/version.rb
|
|
165
189
|
- release-please-config.json
|
|
166
190
|
- renovate.json
|
|
167
191
|
homepage: https://github.com/open-feature/openfeature-ruby
|
|
168
192
|
licenses:
|
|
169
|
-
- Apache-2.0
|
|
193
|
+
- Apache-2.0
|
|
170
194
|
metadata:
|
|
171
195
|
homepage_uri: https://github.com/open-feature/openfeature-ruby
|
|
172
196
|
source_code_uri: https://github.com/open-feature/openfeature-ruby
|