openfeature-sdk 0.6.0 → 0.6.2
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/CHANGELOG.md +20 -0
- data/Gemfile.lock +1 -1
- data/README.md +168 -31
- data/lib/open_feature/sdk/api.rb +14 -0
- data/lib/open_feature/sdk/client.rb +48 -5
- data/lib/open_feature/sdk/configuration.rb +28 -9
- data/lib/open_feature/sdk/evaluation_context_builder.rb +2 -2
- data/lib/open_feature/sdk/hooks/hook_context.rb +8 -0
- data/lib/open_feature/sdk/hooks/logging_hook.rb +84 -0
- data/lib/open_feature/sdk/hooks.rb +1 -0
- data/lib/open_feature/sdk/provider/in_memory_provider.rb +22 -5
- data/lib/open_feature/sdk/provider/resolution_details.rb +14 -1
- data/lib/open_feature/sdk/provider_state_registry.rb +21 -3
- data/lib/open_feature/sdk/thread_local_transaction_context_propagator.rb +19 -0
- data/lib/open_feature/sdk/tracking_event_details.rb +23 -0
- data/lib/open_feature/sdk/transaction_context_propagator.rb +15 -0
- data/lib/open_feature/sdk/version.rb +1 -1
- data/lib/open_feature/sdk.rb +2 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 304772054cf10c0cc02870fdeb7a1a7412342912e3f8a08d80c33fe25bd99849
|
|
4
|
+
data.tar.gz: 6a27c2a8bf23074292594f4eec0ecd4dd9df11ddc1e3e48c4c7ff245e76a6381
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bcaacd882ecdf0ebd0643e1a839bae3d4efb02deb30f2128c5feca91c93e85bee65b755147cf4bef6edabc010875746b777cd4570f26161451a620db396e02e8
|
|
7
|
+
data.tar.gz: 5a6ba82992f4d8e960d350a573e48a5e015c9b8eb953737354a4573d0a9f89ca6c4615553d0240a91c1490aad8fc3e1c2ca4bfeddc64d0b1203835ede55e1c77
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.2](https://github.com/open-feature/ruby-sdk/compare/v0.6.1...v0.6.2) (2026-03-07)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* 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))
|
|
9
|
+
* 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))
|
|
10
|
+
|
|
11
|
+
## [0.6.1](https://github.com/open-feature/ruby-sdk/compare/v0.6.0...v0.6.1) (2026-03-05)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
* add flag metadata defaulting and immutability ([#221](https://github.com/open-feature/ruby-sdk/issues/221)) ([a300fc5](https://github.com/open-feature/ruby-sdk/commit/a300fc559293169f22eb1ce26f738cdee664cd26))
|
|
17
|
+
* add hook data per-hook mutable state ([#222](https://github.com/open-feature/ruby-sdk/issues/222)) ([28518a0](https://github.com/open-feature/ruby-sdk/commit/28518a0e08143d167b9d34c86e57a583fe5ee0de))
|
|
18
|
+
* add InMemoryProvider context callbacks and event emission ([#224](https://github.com/open-feature/ruby-sdk/issues/224)) ([0a148f6](https://github.com/open-feature/ruby-sdk/commit/0a148f66abc815fc2ec9fd70027075125dbd504a))
|
|
19
|
+
* add shutdown API, provider status, and status short-circuit ([#223](https://github.com/open-feature/ruby-sdk/issues/223)) ([f9c32ad](https://github.com/open-feature/ruby-sdk/commit/f9c32ad1b467af25697423a542bc568597f39743))
|
|
20
|
+
* implement Tracking API (spec section 6) ([#227](https://github.com/open-feature/ruby-sdk/issues/227)) ([5576fce](https://github.com/open-feature/ruby-sdk/commit/5576fce1c3bcf6e7510d8957c7e40e85c4b83b6f))
|
|
21
|
+
* populate event details payload with error_code and message ([#225](https://github.com/open-feature/ruby-sdk/issues/225)) ([a185003](https://github.com/open-feature/ruby-sdk/commit/a185003dc09a69b2dda1fe569d1f82c45979cdad))
|
|
22
|
+
|
|
3
23
|
## [0.6.0](https://github.com/open-feature/ruby-sdk/compare/v0.5.1...v0.6.0) (2026-03-05)
|
|
4
24
|
|
|
5
25
|
|
data/Gemfile.lock
CHANGED
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.
|
|
21
|
-
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.6.
|
|
20
|
+
<a href="https://github.com/open-feature/ruby-sdk/releases/tag/v0.6.2">
|
|
21
|
+
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.6.2&color=blue&style=for-the-badge" />
|
|
22
22
|
</a>
|
|
23
23
|
|
|
24
24
|
<!-- x-release-please-end -->
|
|
@@ -100,13 +100,14 @@ object = client.fetch_object_value(flag_key: 'object_value', default_value: { na
|
|
|
100
100
|
| ------ | --------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
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
|
-
|
|
|
104
|
-
|
|
|
103
|
+
| ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
|
|
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
|
-
|
|
|
108
|
-
|
|
|
109
|
-
|
|
|
107
|
+
| ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
|
|
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) |
|
|
110
|
+
| ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
|
|
110
111
|
|
|
111
112
|
<sub>Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌</sub>
|
|
112
113
|
|
|
@@ -200,21 +201,73 @@ bool_value = client.fetch_boolean_value(
|
|
|
200
201
|
|
|
201
202
|
### Hooks
|
|
202
203
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
<!-- [Hooks](https://openfeature.dev/docs/reference/concepts/hooks) allow for custom logic to be added at well-defined points of the flag evaluation life-cycle.
|
|
204
|
+
[Hooks](https://openfeature.dev/docs/reference/concepts/hooks) allow for custom logic to be added at well-defined points of the flag evaluation life-cycle.
|
|
206
205
|
Look [here](https://openfeature.dev/ecosystem/?instant_search%5BrefinementList%5D%5Btype%5D%5B0%5D=Hook&instant_search%5BrefinementList%5D%5Btechnology%5D%5B0%5D=Ruby) for a complete list of available hooks.
|
|
207
206
|
If the hook you're looking for hasn't been created yet, see the [develop a hook](#develop-a-hook) section to learn how to build it yourself.
|
|
208
207
|
|
|
209
|
-
|
|
208
|
+
Hooks can be registered at the global, client, or flag invocation level.
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
# Define a hook
|
|
212
|
+
class MyHook
|
|
213
|
+
include OpenFeature::SDK::Hooks::Hook
|
|
214
|
+
|
|
215
|
+
def before(hook_context:, hints:)
|
|
216
|
+
puts "Evaluating flag: #{hook_context.flag_key}"
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def after(hook_context:, evaluation_details:, hints:)
|
|
221
|
+
puts "Flag #{hook_context.flag_key} evaluated to: #{evaluation_details.value}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def error(hook_context:, exception:, hints:)
|
|
225
|
+
puts "Error evaluating #{hook_context.flag_key}: #{exception.message}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def finally(hook_context:, evaluation_details:, hints:)
|
|
229
|
+
puts "Evaluation complete for #{hook_context.flag_key}"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
210
232
|
|
|
211
|
-
|
|
233
|
+
# Register at the API (global) level
|
|
234
|
+
OpenFeature::SDK.hooks << MyHook.new
|
|
235
|
+
|
|
236
|
+
# Register at the client level
|
|
237
|
+
client = OpenFeature::SDK.build_client
|
|
238
|
+
client.hooks << MyHook.new
|
|
239
|
+
|
|
240
|
+
# Register at the invocation level
|
|
241
|
+
client.fetch_boolean_value(
|
|
242
|
+
flag_key: "my-flag",
|
|
243
|
+
default_value: false,
|
|
244
|
+
hooks: [MyHook.new]
|
|
245
|
+
)
|
|
246
|
+
```
|
|
212
247
|
|
|
213
248
|
### Logging
|
|
214
249
|
|
|
215
|
-
|
|
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.
|
|
216
251
|
|
|
217
|
-
|
|
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`
|
|
218
271
|
|
|
219
272
|
### Domains
|
|
220
273
|
|
|
@@ -276,28 +329,94 @@ OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY,
|
|
|
276
329
|
|
|
277
330
|
### Shutdown
|
|
278
331
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
<!-- TODO The OpenFeature API provides a close function to perform a cleanup of all registered providers.
|
|
332
|
+
The OpenFeature API provides a `shutdown` method to perform cleanup of all registered providers.
|
|
282
333
|
This should only be called when your application is in the process of shutting down.
|
|
283
334
|
|
|
335
|
+
```ruby
|
|
336
|
+
# Shut down all registered providers and clear state
|
|
337
|
+
OpenFeature::SDK.shutdown
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Individual providers can implement a `shutdown` method to perform cleanup:
|
|
341
|
+
|
|
284
342
|
```ruby
|
|
285
343
|
class MyProvider
|
|
286
344
|
def shutdown
|
|
287
345
|
# Perform any shutdown/reclamation steps with flag management system here
|
|
288
|
-
# Return value is ignored
|
|
289
346
|
end
|
|
290
347
|
end
|
|
291
|
-
```
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### Tracking
|
|
351
|
+
|
|
352
|
+
The tracking API allows you to use OpenFeature abstractions and objects to associate user actions with feature flag evaluations.
|
|
353
|
+
This is essential for robust experimentation powered by feature flags.
|
|
354
|
+
For example, a flag enhancing the appearance of a UI component might drive user engagement to a new feature; to test this hypothesis, telemetry collected by a [hook](#hooks) or [provider](#providers) can be associated with telemetry reported in the client's `track` function.
|
|
355
|
+
|
|
356
|
+
```ruby
|
|
357
|
+
client = OpenFeature::SDK.build_client
|
|
358
|
+
|
|
359
|
+
# Simple tracking event
|
|
360
|
+
client.track("checkout_completed")
|
|
361
|
+
|
|
362
|
+
# With evaluation context
|
|
363
|
+
client.track(
|
|
364
|
+
"purchase",
|
|
365
|
+
evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123")
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# With tracking event details (optional numeric value + custom fields)
|
|
369
|
+
details = OpenFeature::SDK::TrackingEventDetails.new(
|
|
370
|
+
value: 99.99,
|
|
371
|
+
plan: "premium",
|
|
372
|
+
currency: "USD"
|
|
373
|
+
)
|
|
374
|
+
client.track("subscription", tracking_event_details: details)
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Note that some providers may not support tracking; if the provider does not implement a `track` method, the call is a no-op.
|
|
378
|
+
Check the documentation for your [provider](#providers) for more information.
|
|
292
379
|
|
|
293
380
|
### Transaction Context Propagation
|
|
294
381
|
|
|
295
|
-
|
|
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`:
|
|
296
386
|
|
|
297
|
-
|
|
298
|
-
|
|
387
|
+
```ruby
|
|
388
|
+
# Set up the propagator
|
|
389
|
+
OpenFeature::SDK.set_transaction_context_propagator(
|
|
390
|
+
OpenFeature::SDK::ThreadLocalTransactionContextPropagator.new
|
|
391
|
+
)
|
|
299
392
|
|
|
300
|
-
|
|
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:
|
|
405
|
+
|
|
406
|
+
```ruby
|
|
407
|
+
class MyRequestScopedPropagator
|
|
408
|
+
include OpenFeature::SDK::TransactionContextPropagator
|
|
409
|
+
|
|
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
|
+
```
|
|
301
420
|
|
|
302
421
|
## Extending
|
|
303
422
|
|
|
@@ -344,6 +463,12 @@ class MyProvider
|
|
|
344
463
|
def fetch_object_value(flag_key:, default_value:, evaluation_context: nil)
|
|
345
464
|
# Retrieve a hash value from provider source
|
|
346
465
|
end
|
|
466
|
+
|
|
467
|
+
# Optional: implement tracking support (spec 6.1.4)
|
|
468
|
+
# If not defined, Client#track is a no-op
|
|
469
|
+
def track(tracking_event_name, evaluation_context:, tracking_event_details:)
|
|
470
|
+
# Record a tracking event with your flag management system
|
|
471
|
+
end
|
|
347
472
|
end
|
|
348
473
|
```
|
|
349
474
|
|
|
@@ -351,17 +476,29 @@ end
|
|
|
351
476
|
|
|
352
477
|
### Develop a hook
|
|
353
478
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
<!-- To develop a hook, you need to create a new project and include the OpenFeature SDK as a dependency.
|
|
479
|
+
To develop a hook, you need to create a new project and include the OpenFeature SDK as a dependency.
|
|
357
480
|
This can be a new repository or included in [the existing contrib repository](https://github.com/open-feature/ruby-sdk-contrib) available under the OpenFeature organization.
|
|
358
|
-
Implement your own hook by
|
|
359
|
-
|
|
360
|
-
To avoid defining empty functions, make use of the `UnimplementedHook` struct (which already implements all the empty functions). -->
|
|
481
|
+
Implement your own hook by including the `OpenFeature::SDK::Hooks::Hook` module.
|
|
482
|
+
You only need to define the stages you care about — unimplemented stages are no-ops by default.
|
|
361
483
|
|
|
362
|
-
|
|
484
|
+
```ruby
|
|
485
|
+
class MyLoggingHook
|
|
486
|
+
include OpenFeature::SDK::Hooks::Hook
|
|
487
|
+
|
|
488
|
+
def before(hook_context:, hints:)
|
|
489
|
+
puts "Evaluating #{hook_context.flag_key}"
|
|
490
|
+
nil # Return nil or an EvaluationContext to merge
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def after(hook_context:, evaluation_details:, hints:)
|
|
494
|
+
puts "Result: #{evaluation_details.value}"
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# error and finally are optional — only define what you need
|
|
498
|
+
end
|
|
499
|
+
```
|
|
363
500
|
|
|
364
|
-
|
|
501
|
+
> Built a new hook? [Let us know](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=hook&projects=&template=document-hook.yaml&title=%5BHook%5D%3A+) so we can add it to the docs!
|
|
365
502
|
|
|
366
503
|
<!-- x-hide-in-docs-start -->
|
|
367
504
|
## ⭐️ Support the project
|
data/lib/open_feature/sdk/api.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative "evaluation_details"
|
|
|
10
10
|
require_relative "client_metadata"
|
|
11
11
|
require_relative "hooks"
|
|
12
12
|
require_relative "client"
|
|
13
|
+
require_relative "tracking_event_details"
|
|
13
14
|
require_relative "provider"
|
|
14
15
|
|
|
15
16
|
module OpenFeature
|
|
@@ -68,6 +69,19 @@ module OpenFeature
|
|
|
68
69
|
def logger=(new_logger)
|
|
69
70
|
configuration.logger = new_logger
|
|
70
71
|
end
|
|
72
|
+
|
|
73
|
+
def set_transaction_context_propagator(propagator)
|
|
74
|
+
configuration.transaction_context_propagator = propagator
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def set_transaction_context(evaluation_context)
|
|
78
|
+
propagator = configuration.transaction_context_propagator
|
|
79
|
+
propagator&.set_transaction_context(evaluation_context)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def shutdown
|
|
83
|
+
configuration.shutdown
|
|
84
|
+
end
|
|
71
85
|
end
|
|
72
86
|
end
|
|
73
87
|
end
|
|
@@ -28,6 +28,10 @@ module OpenFeature
|
|
|
28
28
|
@hooks = []
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
def provider_status
|
|
32
|
+
OpenFeature::SDK.configuration.provider_state(@provider)
|
|
33
|
+
end
|
|
34
|
+
|
|
31
35
|
def add_handler(event_type, handler = nil, &block)
|
|
32
36
|
actual_handler = handler || block
|
|
33
37
|
OpenFeature::SDK.configuration.add_client_handler(self, event_type, actual_handler)
|
|
@@ -38,6 +42,18 @@ module OpenFeature
|
|
|
38
42
|
OpenFeature::SDK.configuration.remove_client_handler(self, event_type, actual_handler)
|
|
39
43
|
end
|
|
40
44
|
|
|
45
|
+
# Tracking API (spec 6.1.1.1) — dynamic-context paradigm
|
|
46
|
+
#
|
|
47
|
+
# Records a tracking event. If the provider does not implement
|
|
48
|
+
# tracking, this is a no-op (spec 6.1.4).
|
|
49
|
+
def track(tracking_event_name, evaluation_context: nil, tracking_event_details: nil)
|
|
50
|
+
return unless @provider.respond_to?(:track)
|
|
51
|
+
|
|
52
|
+
built_context = build_evaluation_context(evaluation_context)
|
|
53
|
+
|
|
54
|
+
@provider.track(tracking_event_name, evaluation_context: built_context, tracking_event_details: tracking_event_details)
|
|
55
|
+
end
|
|
56
|
+
|
|
41
57
|
RESULT_TYPE.each do |result_type|
|
|
42
58
|
SUFFIXES.each do |suffix|
|
|
43
59
|
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
|
@@ -54,11 +70,19 @@ module OpenFeature
|
|
|
54
70
|
def fetch_details(type:, flag_key:, default_value:, evaluation_context: nil, invocation_hooks: [], hook_hints: nil)
|
|
55
71
|
validate_default_value_type(type, default_value)
|
|
56
72
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
73
|
+
if OpenFeature::SDK.configuration.provider_tracked?(@provider)
|
|
74
|
+
error_code = short_circuit_error_code(provider_status)
|
|
75
|
+
if error_code
|
|
76
|
+
resolution = Provider::ResolutionDetails.new(
|
|
77
|
+
value: default_value,
|
|
78
|
+
error_code: error_code,
|
|
79
|
+
reason: Provider::Reason::ERROR
|
|
80
|
+
)
|
|
81
|
+
return EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
built_context = build_evaluation_context(evaluation_context)
|
|
62
86
|
|
|
63
87
|
# Assemble ordered hooks: API → Client → Invocation → Provider (spec 4.4.2)
|
|
64
88
|
provider_hooks = @provider.respond_to?(:hooks) ? Array(@provider.hooks) : []
|
|
@@ -109,6 +133,25 @@ module OpenFeature
|
|
|
109
133
|
EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution_details)
|
|
110
134
|
end
|
|
111
135
|
|
|
136
|
+
def short_circuit_error_code(state)
|
|
137
|
+
case state
|
|
138
|
+
when ProviderState::NOT_READY then Provider::ErrorCode::PROVIDER_NOT_READY
|
|
139
|
+
when ProviderState::FATAL then Provider::ErrorCode::PROVIDER_FATAL
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def build_evaluation_context(invocation_context)
|
|
144
|
+
propagator = OpenFeature::SDK.configuration.transaction_context_propagator
|
|
145
|
+
transaction_context = propagator&.get_transaction_context
|
|
146
|
+
|
|
147
|
+
EvaluationContextBuilder.new.call(
|
|
148
|
+
api_context: OpenFeature::SDK.evaluation_context,
|
|
149
|
+
transaction_context: transaction_context,
|
|
150
|
+
client_context: evaluation_context,
|
|
151
|
+
invocation_context: invocation_context
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
112
155
|
def validate_default_value_type(type, default_value)
|
|
113
156
|
expected_classes = TYPE_CLASS_MAP[type]
|
|
114
157
|
unless expected_classes.any? { |klass| default_value.is_a?(klass) }
|
|
@@ -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
|
|
@@ -76,6 +76,26 @@ module OpenFeature
|
|
|
76
76
|
set_provider_internal(provider, domain: domain, wait_for_init: true)
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
def provider_state(provider)
|
|
80
|
+
@provider_state_registry.get_state(provider)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def provider_tracked?(provider)
|
|
84
|
+
@provider_state_registry.tracked?(provider)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def shutdown
|
|
88
|
+
providers_to_shutdown = @provider_mutex.synchronize { @providers.values.uniq }
|
|
89
|
+
|
|
90
|
+
providers_to_shutdown.each do |prov|
|
|
91
|
+
prov.shutdown if prov.respond_to?(:shutdown)
|
|
92
|
+
rescue => e
|
|
93
|
+
@logger&.warn("Error shutting down provider #{prov&.class&.name || "unknown"}: #{e.message}")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
reset
|
|
97
|
+
end
|
|
98
|
+
|
|
79
99
|
private
|
|
80
100
|
|
|
81
101
|
def reset
|
|
@@ -171,10 +191,6 @@ module OpenFeature
|
|
|
171
191
|
run_handlers_for_provider(provider, event_type, event_details)
|
|
172
192
|
end
|
|
173
193
|
|
|
174
|
-
def provider_state(provider)
|
|
175
|
-
@provider_state_registry.get_state(provider)
|
|
176
|
-
end
|
|
177
|
-
|
|
178
194
|
private
|
|
179
195
|
|
|
180
196
|
def extract_provider_name(provider)
|
|
@@ -219,8 +235,7 @@ module OpenFeature
|
|
|
219
235
|
provider_state = @provider_state_registry.get_state(provider)
|
|
220
236
|
|
|
221
237
|
if event_type == status_to_event[provider_state]
|
|
222
|
-
|
|
223
|
-
event_details = {provider_name: provider_name}
|
|
238
|
+
event_details = build_event_details(provider)
|
|
224
239
|
|
|
225
240
|
begin
|
|
226
241
|
handler.call(event_details)
|
|
@@ -237,8 +252,7 @@ module OpenFeature
|
|
|
237
252
|
provider_state = @provider_state_registry.get_state(client_provider)
|
|
238
253
|
|
|
239
254
|
if event_type == status_to_event[provider_state]
|
|
240
|
-
|
|
241
|
-
event_details = {provider_name: provider_name}
|
|
255
|
+
event_details = build_event_details(client_provider)
|
|
242
256
|
|
|
243
257
|
begin
|
|
244
258
|
handler.call(event_details)
|
|
@@ -248,6 +262,11 @@ module OpenFeature
|
|
|
248
262
|
end
|
|
249
263
|
end
|
|
250
264
|
end
|
|
265
|
+
|
|
266
|
+
def build_event_details(provider)
|
|
267
|
+
stored_details = @provider_state_registry.get_details(provider)
|
|
268
|
+
{provider_name: extract_provider_name(provider)}.merge(stored_details)
|
|
269
|
+
end
|
|
251
270
|
end
|
|
252
271
|
end
|
|
253
272
|
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
|
|
|
@@ -22,6 +22,14 @@ module OpenFeature
|
|
|
22
22
|
@evaluation_context = evaluation_context
|
|
23
23
|
@client_metadata = client_metadata
|
|
24
24
|
@provider_metadata = provider_metadata
|
|
25
|
+
@hook_data = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns a mutable hash scoped to the given hook instance.
|
|
29
|
+
# The same hash is returned across all hook stages (before, after, error, finally),
|
|
30
|
+
# allowing hooks to share state across their lifecycle (spec 4.1.5, 4.6.1).
|
|
31
|
+
def hook_data_for(hook)
|
|
32
|
+
@hook_data[hook.object_id] ||= {}
|
|
25
33
|
end
|
|
26
34
|
end
|
|
27
35
|
end
|
|
@@ -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
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
module OpenFeature
|
|
4
4
|
module SDK
|
|
5
5
|
module Provider
|
|
6
|
-
# TODO: Add evaluation context support
|
|
7
6
|
class InMemoryProvider
|
|
7
|
+
include Provider::EventEmitter
|
|
8
|
+
|
|
8
9
|
NAME = "In-memory Provider"
|
|
9
10
|
|
|
10
11
|
attr_reader :metadata
|
|
@@ -24,7 +25,12 @@ module OpenFeature
|
|
|
24
25
|
|
|
25
26
|
def add_flag(flag_key:, value:)
|
|
26
27
|
flags[flag_key] = value
|
|
27
|
-
|
|
28
|
+
emit_provider_changed([flag_key])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def update_flags(new_flags)
|
|
32
|
+
@flags = new_flags.dup
|
|
33
|
+
emit_provider_changed(new_flags.keys)
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
|
|
@@ -56,13 +62,24 @@ module OpenFeature
|
|
|
56
62
|
attr_reader :flags
|
|
57
63
|
|
|
58
64
|
def fetch_value(flag_key:, default_value:, evaluation_context:)
|
|
59
|
-
|
|
65
|
+
raw_value = flags[flag_key]
|
|
60
66
|
|
|
61
|
-
if
|
|
67
|
+
if raw_value.nil?
|
|
62
68
|
return ResolutionDetails.new(value: default_value, error_code: ErrorCode::FLAG_NOT_FOUND, reason: Reason::ERROR)
|
|
63
69
|
end
|
|
64
70
|
|
|
65
|
-
|
|
71
|
+
if raw_value.respond_to?(:call)
|
|
72
|
+
value = raw_value.call(evaluation_context)
|
|
73
|
+
ResolutionDetails.new(value: value, reason: Reason::TARGETING_MATCH)
|
|
74
|
+
else
|
|
75
|
+
ResolutionDetails.new(value: raw_value, reason: Reason::STATIC)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def emit_provider_changed(flag_keys)
|
|
80
|
+
return unless configuration_attached?
|
|
81
|
+
|
|
82
|
+
emit_event(ProviderEvent::PROVIDER_CONFIGURATION_CHANGED, flags_changed: flag_keys)
|
|
66
83
|
end
|
|
67
84
|
end
|
|
68
85
|
end
|
|
@@ -3,7 +3,20 @@
|
|
|
3
3
|
module OpenFeature
|
|
4
4
|
module SDK
|
|
5
5
|
module Provider
|
|
6
|
-
|
|
6
|
+
EMPTY_FLAG_METADATA = {}.freeze
|
|
7
|
+
|
|
8
|
+
ResolutionDetails = Struct.new(:value, :reason, :variant, :error_code, :error_message, :flag_metadata, keyword_init: true) do
|
|
9
|
+
def flag_metadata
|
|
10
|
+
raw = self[:flag_metadata]
|
|
11
|
+
if raw.nil?
|
|
12
|
+
EMPTY_FLAG_METADATA
|
|
13
|
+
elsif raw.frozen?
|
|
14
|
+
raw
|
|
15
|
+
else
|
|
16
|
+
raw.dup.freeze
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
7
20
|
end
|
|
8
21
|
end
|
|
9
22
|
end
|
|
@@ -17,7 +17,7 @@ module OpenFeature
|
|
|
17
17
|
return unless provider
|
|
18
18
|
|
|
19
19
|
@mutex.synchronize do
|
|
20
|
-
@states[provider.object_id] = state
|
|
20
|
+
@states[provider.object_id] = {state: state, details: {}}
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
@@ -29,7 +29,7 @@ module OpenFeature
|
|
|
29
29
|
# Only update state if the event should cause a state change
|
|
30
30
|
if new_state
|
|
31
31
|
@mutex.synchronize do
|
|
32
|
-
@states[provider.object_id] = new_state
|
|
32
|
+
@states[provider.object_id] = {state: new_state, details: event_details || {}}
|
|
33
33
|
end
|
|
34
34
|
new_state
|
|
35
35
|
else
|
|
@@ -42,7 +42,17 @@ module OpenFeature
|
|
|
42
42
|
return ProviderState::NOT_READY unless provider
|
|
43
43
|
|
|
44
44
|
@mutex.synchronize do
|
|
45
|
-
@states[provider.object_id]
|
|
45
|
+
entry = @states[provider.object_id]
|
|
46
|
+
entry ? entry[:state] : ProviderState::NOT_READY
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def get_details(provider)
|
|
51
|
+
return {} unless provider
|
|
52
|
+
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
entry = @states[provider.object_id]
|
|
55
|
+
entry ? entry[:details] : {}
|
|
46
56
|
end
|
|
47
57
|
end
|
|
48
58
|
|
|
@@ -54,6 +64,14 @@ module OpenFeature
|
|
|
54
64
|
end
|
|
55
65
|
end
|
|
56
66
|
|
|
67
|
+
def tracked?(provider)
|
|
68
|
+
return false unless provider
|
|
69
|
+
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
@states.key?(provider.object_id)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
57
75
|
def ready?(provider)
|
|
58
76
|
get_state(provider) == ProviderState::READY
|
|
59
77
|
end
|
|
@@ -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,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenFeature
|
|
4
|
+
module SDK
|
|
5
|
+
# Represents tracking event details per spec section 6.2.
|
|
6
|
+
#
|
|
7
|
+
# Requirement 6.2.1: MUST define an optional numeric value.
|
|
8
|
+
# Requirement 6.2.2: MUST support custom fields (string keys,
|
|
9
|
+
# boolean/string/number/structure values).
|
|
10
|
+
class TrackingEventDetails
|
|
11
|
+
attr_reader :value, :fields
|
|
12
|
+
|
|
13
|
+
def initialize(value: nil, **fields)
|
|
14
|
+
if !value.nil? && !value.is_a?(Numeric)
|
|
15
|
+
raise ArgumentError, "Tracking event value must be Numeric, got #{value.class}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
@value = value
|
|
19
|
+
@fields = fields.transform_keys(&:to_s)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
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
|
data/lib/open_feature/sdk.rb
CHANGED
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.
|
|
4
|
+
version: 0.6.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- OpenFeature Authors
|
|
@@ -173,6 +173,7 @@ files:
|
|
|
173
173
|
- lib/open_feature/sdk/hooks/hook.rb
|
|
174
174
|
- lib/open_feature/sdk/hooks/hook_context.rb
|
|
175
175
|
- lib/open_feature/sdk/hooks/hook_executor.rb
|
|
176
|
+
- lib/open_feature/sdk/hooks/logging_hook.rb
|
|
176
177
|
- lib/open_feature/sdk/provider.rb
|
|
177
178
|
- lib/open_feature/sdk/provider/error_code.rb
|
|
178
179
|
- lib/open_feature/sdk/provider/event_emitter.rb
|
|
@@ -185,6 +186,9 @@ files:
|
|
|
185
186
|
- lib/open_feature/sdk/provider_initialization_error.rb
|
|
186
187
|
- lib/open_feature/sdk/provider_state.rb
|
|
187
188
|
- lib/open_feature/sdk/provider_state_registry.rb
|
|
189
|
+
- lib/open_feature/sdk/thread_local_transaction_context_propagator.rb
|
|
190
|
+
- lib/open_feature/sdk/tracking_event_details.rb
|
|
191
|
+
- lib/open_feature/sdk/transaction_context_propagator.rb
|
|
188
192
|
- lib/open_feature/sdk/version.rb
|
|
189
193
|
- release-please-config.json
|
|
190
194
|
- renovate.json
|