openfeature-sdk 0.6.2 → 0.6.4
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 +3 -0
- data/Gemfile.lock +39 -1
- data/README.md +2 -2
- data/Rakefile +5 -0
- data/cucumber.yml +2 -0
- data/docs/plans/2026-03-07-telemetry-utility-design.md +98 -0
- data/docs/plans/2026-03-07-telemetry-utility-plan.md +578 -0
- data/lib/open_feature/sdk/api.rb +6 -1
- data/lib/open_feature/sdk/client.rb +46 -15
- data/lib/open_feature/sdk/client_metadata.rb +3 -1
- data/lib/open_feature/sdk/configuration.rb +47 -18
- data/lib/open_feature/sdk/hooks/hook_executor.rb +23 -1
- data/lib/open_feature/sdk/provider/in_memory_provider.rb +3 -1
- data/lib/open_feature/sdk/telemetry.rb +76 -0
- data/lib/open_feature/sdk/version.rb +1 -1
- data/lib/open_feature/sdk.rb +1 -0
- data/renovate.json +3 -2
- 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: 4aa61c48f017d3311bfce9022a5e08d023ee1e2c09eb8799ff0e0a8424cf9fe3
|
|
4
|
+
data.tar.gz: 8c559636076a0ec01911e9057d57cde4e430a7bc4b2543835dcd90527e0f0c0e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0f683b0d315b225259e540fb9f7d86f60a7e334c1501b7f05d45d677d1e7ee6cfb951b463514c05d9c64b5b413935d1bdf95cd684aa5f05136ed87a6cf2ba122
|
|
7
|
+
data.tar.gz: 618dcac7749c461bb77b92f7f7b68e2071b4b71825d05330bb18a08df3a929019ef0fa8d12d79b117fdd3fce431d48f5e15d2f90aa884fa4f28b1226229f9db0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.4](https://github.com/open-feature/ruby-sdk/compare/v0.6.3...v0.6.4) (2026-03-07)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add OTel-compatible telemetry utility ([#240](https://github.com/open-feature/ruby-sdk/issues/240)) ([a03e524](https://github.com/open-feature/ruby-sdk/commit/a03e524681a38c8762257049fae360fa15fcfba3))
|
|
9
|
+
|
|
10
|
+
## [0.6.3](https://github.com/open-feature/ruby-sdk/compare/v0.6.2...v0.6.3) (2026-03-07)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* 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))
|
|
16
|
+
* 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))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Bug Fixes
|
|
20
|
+
|
|
21
|
+
* 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))
|
|
22
|
+
|
|
3
23
|
## [0.6.2](https://github.com/open-feature/ruby-sdk/compare/v0.6.1...v0.6.2) (2026-03-07)
|
|
4
24
|
|
|
5
25
|
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
|
@@ -1,12 +1,40 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
openfeature-sdk (0.6.
|
|
4
|
+
openfeature-sdk (0.6.4)
|
|
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.
|
|
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.4">
|
|
21
|
+
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.6.4&color=blue&style=for-the-badge" />
|
|
22
22
|
</a>
|
|
23
23
|
|
|
24
24
|
<!-- x-release-please-end -->
|
data/Rakefile
CHANGED
data/cucumber.yml
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Telemetry Utility Design
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Add an OTel-compatible telemetry utility to the OpenFeature Ruby SDK that creates
|
|
6
|
+
structured evaluation events from hook context and evaluation details. This utility
|
|
7
|
+
is dependency-free (no OTel gem required) and follows the pattern established by
|
|
8
|
+
the Go SDK's `telemetry` package.
|
|
9
|
+
|
|
10
|
+
Addresses: https://github.com/open-feature/ruby-sdk/issues/176
|
|
11
|
+
|
|
12
|
+
## References
|
|
13
|
+
|
|
14
|
+
- [OpenFeature Spec Appendix D (Observability)](https://openfeature.dev/specification/appendix-d/)
|
|
15
|
+
- [OTel Semantic Conventions for Feature Flags](https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/)
|
|
16
|
+
- [Go SDK telemetry package](https://github.com/open-feature/go-sdk/tree/main/openfeature/telemetry)
|
|
17
|
+
- [JS SDK reference PR](https://github.com/open-feature/js-sdk/pull/1120)
|
|
18
|
+
|
|
19
|
+
## Design Decisions
|
|
20
|
+
|
|
21
|
+
1. **Single public method** accepting `hook_context:` and `evaluation_details:` keyword
|
|
22
|
+
arguments — mirrors the `finally` hook stage signature for zero-friction integration.
|
|
23
|
+
2. **Returns a Struct** (`EvaluationEvent`) with `name` and `attributes` fields — matches
|
|
24
|
+
the SDK's existing Struct conventions (`ResolutionDetails`, `ClientMetadata`, etc.).
|
|
25
|
+
3. **Constants in the Telemetry module directly** — flat namespace matching Go SDK and
|
|
26
|
+
existing Ruby SDK patterns (e.g., `Provider::Reason`).
|
|
27
|
+
4. **Hard-coded metadata mappings only** — maps `contextId`, `flagSetId`, `version` from
|
|
28
|
+
flag metadata to OTel keys. Unknown metadata keys are ignored. Custom attributes can
|
|
29
|
+
be added via hooks in ruby-sdk-contrib.
|
|
30
|
+
5. **No third-party dependencies** — pure data transformation using only standard library.
|
|
31
|
+
|
|
32
|
+
## File Structure
|
|
33
|
+
|
|
34
|
+
- `lib/open_feature/sdk/telemetry.rb` — module with constants, struct, and utility function
|
|
35
|
+
- `spec/open_feature/sdk/telemetry_spec.rb` — tests
|
|
36
|
+
- `lib/open_feature/sdk.rb` — add `require_relative "sdk/telemetry"`
|
|
37
|
+
|
|
38
|
+
## Constants
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
EVENT_NAME = "feature_flag.evaluation"
|
|
42
|
+
|
|
43
|
+
FLAG_KEY = "feature_flag.key"
|
|
44
|
+
CONTEXT_ID_KEY = "feature_flag.context.id"
|
|
45
|
+
ERROR_MESSAGE_KEY = "error.message"
|
|
46
|
+
ERROR_TYPE_KEY = "error.type"
|
|
47
|
+
PROVIDER_NAME_KEY = "feature_flag.provider.name"
|
|
48
|
+
RESULT_REASON_KEY = "feature_flag.result.reason"
|
|
49
|
+
RESULT_VALUE_KEY = "feature_flag.result.value"
|
|
50
|
+
RESULT_VARIANT_KEY = "feature_flag.result.variant"
|
|
51
|
+
FLAG_SET_ID_KEY = "feature_flag.set.id"
|
|
52
|
+
VERSION_KEY = "feature_flag.version"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Public API
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
OpenFeature::SDK::Telemetry.create_evaluation_event(
|
|
59
|
+
hook_context:, # Hooks::HookContext
|
|
60
|
+
evaluation_details: # EvaluationDetails or nil
|
|
61
|
+
) # => EvaluationEvent
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Returns `EvaluationEvent = Struct.new(:name, :attributes, keyword_init: true)`.
|
|
65
|
+
|
|
66
|
+
## Attribute Population Rules
|
|
67
|
+
|
|
68
|
+
| Attribute | Source | Condition |
|
|
69
|
+
|-----------|--------|-----------|
|
|
70
|
+
| `feature_flag.key` | `hook_context.flag_key` | Always |
|
|
71
|
+
| `feature_flag.provider.name` | `hook_context.provider_metadata.name` | When present |
|
|
72
|
+
| `feature_flag.result.variant` | `evaluation_details.variant` | When present (takes precedence over value) |
|
|
73
|
+
| `feature_flag.result.value` | `evaluation_details.value` | Only when variant is nil |
|
|
74
|
+
| `feature_flag.result.reason` | `evaluation_details.reason.downcase` | When present |
|
|
75
|
+
| `error.type` | `evaluation_details.error_code.downcase` | When error occurred |
|
|
76
|
+
| `error.message` | `evaluation_details.error_message` | When error occurred |
|
|
77
|
+
| `feature_flag.context.id` | `targeting_key` or metadata `contextId` | Metadata takes precedence |
|
|
78
|
+
| `feature_flag.set.id` | metadata `flagSetId` | When present in flag_metadata |
|
|
79
|
+
| `feature_flag.version` | metadata `version` | When present in flag_metadata |
|
|
80
|
+
|
|
81
|
+
## Error Handling
|
|
82
|
+
|
|
83
|
+
No defensive `rescue` in the utility — it is a pure data transformation. Nil inputs
|
|
84
|
+
are handled via guard clauses. The calling hook is responsible for exception safety
|
|
85
|
+
(consistent with the existing hook executor pattern).
|
|
86
|
+
|
|
87
|
+
## Test Plan
|
|
88
|
+
|
|
89
|
+
1. Happy path with all attributes populated
|
|
90
|
+
2. Variant vs value precedence
|
|
91
|
+
3. Enum downcasing (reason and error_code)
|
|
92
|
+
4. Error attributes present only on error
|
|
93
|
+
5. Nil evaluation_details
|
|
94
|
+
6. Nil/empty flag_metadata
|
|
95
|
+
7. Metadata contextId overrides targeting_key
|
|
96
|
+
8. Targeting key fallback when no contextId
|
|
97
|
+
9. Unknown metadata keys ignored
|
|
98
|
+
10. Return type verification
|
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
# Telemetry Utility Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add a dependency-free `OpenFeature::SDK::Telemetry` module that creates OTel-compatible evaluation events from hook context and evaluation details.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Single module (`Telemetry`) in one file with constants, a Struct, and one public `module_function`. Designed for consumption in a hook's `finally` stage. No third-party dependencies.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby >= 3.1, RSpec, Standard Ruby (linter)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
### Task 1: Create telemetry module with constants and struct
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Create: `lib/open_feature/sdk/telemetry.rb`
|
|
17
|
+
|
|
18
|
+
**Step 1: Create the module file with constants and struct**
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# frozen_string_literal: true
|
|
22
|
+
|
|
23
|
+
module OpenFeature
|
|
24
|
+
module SDK
|
|
25
|
+
module Telemetry
|
|
26
|
+
EVENT_NAME = "feature_flag.evaluation"
|
|
27
|
+
|
|
28
|
+
FLAG_KEY = "feature_flag.key"
|
|
29
|
+
CONTEXT_ID_KEY = "feature_flag.context.id"
|
|
30
|
+
ERROR_MESSAGE_KEY = "error.message"
|
|
31
|
+
ERROR_TYPE_KEY = "error.type"
|
|
32
|
+
PROVIDER_NAME_KEY = "feature_flag.provider.name"
|
|
33
|
+
RESULT_REASON_KEY = "feature_flag.result.reason"
|
|
34
|
+
RESULT_VALUE_KEY = "feature_flag.result.value"
|
|
35
|
+
RESULT_VARIANT_KEY = "feature_flag.result.variant"
|
|
36
|
+
FLAG_SET_ID_KEY = "feature_flag.set.id"
|
|
37
|
+
VERSION_KEY = "feature_flag.version"
|
|
38
|
+
|
|
39
|
+
METADATA_KEY_MAP = {
|
|
40
|
+
"contextId" => CONTEXT_ID_KEY,
|
|
41
|
+
"flagSetId" => FLAG_SET_ID_KEY,
|
|
42
|
+
"version" => VERSION_KEY
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
EvaluationEvent = Struct.new(:name, :attributes, keyword_init: true)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Step 2: Wire up the require**
|
|
52
|
+
|
|
53
|
+
Modify: `lib/open_feature/sdk.rb` — add `require_relative "sdk/telemetry"` after the existing requires (line 3 area).
|
|
54
|
+
|
|
55
|
+
**Step 3: Verify it loads**
|
|
56
|
+
|
|
57
|
+
Run: `bundle exec ruby -e "require 'open_feature/sdk'; puts OpenFeature::SDK::Telemetry::EVENT_NAME"`
|
|
58
|
+
Expected: `feature_flag.evaluation`
|
|
59
|
+
|
|
60
|
+
**Step 4: Commit**
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
git add lib/open_feature/sdk/telemetry.rb lib/open_feature/sdk.rb
|
|
64
|
+
git commit -s -S -m "feat(telemetry): add Telemetry module with OTel constants and EvaluationEvent struct"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
### Task 2: Write tests for happy path and return type
|
|
70
|
+
|
|
71
|
+
**Files:**
|
|
72
|
+
- Create: `spec/open_feature/sdk/telemetry_spec.rb`
|
|
73
|
+
|
|
74
|
+
**Step 1: Write the test file with happy path and return type tests**
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
# frozen_string_literal: true
|
|
78
|
+
|
|
79
|
+
require "spec_helper"
|
|
80
|
+
|
|
81
|
+
RSpec.describe OpenFeature::SDK::Telemetry do
|
|
82
|
+
let(:client_metadata) { OpenFeature::SDK::ClientMetadata.new(domain: "test-domain") }
|
|
83
|
+
let(:provider_metadata) { OpenFeature::SDK::Provider::ProviderMetadata.new(name: "test-provider") }
|
|
84
|
+
let(:evaluation_context) { OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123") }
|
|
85
|
+
|
|
86
|
+
let(:hook_context) do
|
|
87
|
+
OpenFeature::SDK::Hooks::HookContext.new(
|
|
88
|
+
flag_key: "my-flag",
|
|
89
|
+
flag_value_type: :boolean,
|
|
90
|
+
default_value: false,
|
|
91
|
+
evaluation_context: evaluation_context,
|
|
92
|
+
client_metadata: client_metadata,
|
|
93
|
+
provider_metadata: provider_metadata
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
let(:flag_metadata) do
|
|
98
|
+
{
|
|
99
|
+
"contextId" => "ctx-456",
|
|
100
|
+
"flagSetId" => "set-789",
|
|
101
|
+
"version" => "v1.0"
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
let(:resolution_details) do
|
|
106
|
+
OpenFeature::SDK::Provider::ResolutionDetails.new(
|
|
107
|
+
value: true,
|
|
108
|
+
reason: "TARGETING_MATCH",
|
|
109
|
+
variant: "enabled",
|
|
110
|
+
flag_metadata: flag_metadata
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
let(:evaluation_details) do
|
|
115
|
+
OpenFeature::SDK::EvaluationDetails.new(
|
|
116
|
+
flag_key: "my-flag",
|
|
117
|
+
resolution_details: resolution_details
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
describe ".create_evaluation_event" do
|
|
122
|
+
context "with full data" do
|
|
123
|
+
it "returns an EvaluationEvent with all attributes populated" do
|
|
124
|
+
event = described_class.create_evaluation_event(
|
|
125
|
+
hook_context: hook_context,
|
|
126
|
+
evaluation_details: evaluation_details
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
expect(event).to be_a(OpenFeature::SDK::Telemetry::EvaluationEvent)
|
|
130
|
+
expect(event.name).to eq("feature_flag.evaluation")
|
|
131
|
+
expect(event.attributes).to eq(
|
|
132
|
+
"feature_flag.key" => "my-flag",
|
|
133
|
+
"feature_flag.provider.name" => "test-provider",
|
|
134
|
+
"feature_flag.result.variant" => "enabled",
|
|
135
|
+
"feature_flag.result.reason" => "targeting_match",
|
|
136
|
+
"feature_flag.context.id" => "ctx-456",
|
|
137
|
+
"feature_flag.set.id" => "set-789",
|
|
138
|
+
"feature_flag.version" => "v1.0"
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Step 2: Run the test to verify it fails**
|
|
147
|
+
|
|
148
|
+
Run: `bundle exec rspec spec/open_feature/sdk/telemetry_spec.rb`
|
|
149
|
+
Expected: FAIL — `NoMethodError: undefined method 'create_evaluation_event'`
|
|
150
|
+
|
|
151
|
+
**Step 3: Commit the failing test**
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
git add spec/open_feature/sdk/telemetry_spec.rb
|
|
155
|
+
git commit -s -S -m "test(telemetry): add failing test for happy path"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
### Task 3: Implement create_evaluation_event
|
|
161
|
+
|
|
162
|
+
**Files:**
|
|
163
|
+
- Modify: `lib/open_feature/sdk/telemetry.rb`
|
|
164
|
+
|
|
165
|
+
**Step 1: Add the implementation to the Telemetry module**
|
|
166
|
+
|
|
167
|
+
Add after the `EvaluationEvent` struct definition:
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
module_function
|
|
171
|
+
|
|
172
|
+
def create_evaluation_event(hook_context:, evaluation_details:)
|
|
173
|
+
attributes = {FLAG_KEY => hook_context.flag_key}
|
|
174
|
+
|
|
175
|
+
provider_name = hook_context.provider_metadata&.name
|
|
176
|
+
attributes[PROVIDER_NAME_KEY] = provider_name if provider_name
|
|
177
|
+
|
|
178
|
+
targeting_key = hook_context.evaluation_context&.targeting_key
|
|
179
|
+
attributes[CONTEXT_ID_KEY] = targeting_key if targeting_key
|
|
180
|
+
|
|
181
|
+
if evaluation_details
|
|
182
|
+
if evaluation_details.variant
|
|
183
|
+
attributes[RESULT_VARIANT_KEY] = evaluation_details.variant
|
|
184
|
+
else
|
|
185
|
+
attributes[RESULT_VALUE_KEY] = evaluation_details.value
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
if evaluation_details.reason
|
|
189
|
+
attributes[RESULT_REASON_KEY] = evaluation_details.reason.downcase
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
if evaluation_details.error_code
|
|
193
|
+
attributes[ERROR_TYPE_KEY] = evaluation_details.error_code.downcase
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
if evaluation_details.error_message
|
|
197
|
+
attributes[ERROR_MESSAGE_KEY] = evaluation_details.error_message
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
extract_metadata(evaluation_details.flag_metadata, attributes)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
EvaluationEvent.new(name: EVENT_NAME, attributes: attributes)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def extract_metadata(flag_metadata, attributes)
|
|
207
|
+
return unless flag_metadata
|
|
208
|
+
|
|
209
|
+
METADATA_KEY_MAP.each do |metadata_key, otel_key|
|
|
210
|
+
value = flag_metadata[metadata_key]
|
|
211
|
+
attributes[otel_key] = value unless value.nil?
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private_class_method :extract_metadata
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
**Step 2: Run the test to verify it passes**
|
|
219
|
+
|
|
220
|
+
Run: `bundle exec rspec spec/open_feature/sdk/telemetry_spec.rb`
|
|
221
|
+
Expected: PASS (1 example, 0 failures)
|
|
222
|
+
|
|
223
|
+
**Step 3: Commit**
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
git add lib/open_feature/sdk/telemetry.rb
|
|
227
|
+
git commit -s -S -m "feat(telemetry): implement create_evaluation_event"
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
### Task 4: Add tests for variant vs value precedence
|
|
233
|
+
|
|
234
|
+
**Files:**
|
|
235
|
+
- Modify: `spec/open_feature/sdk/telemetry_spec.rb`
|
|
236
|
+
|
|
237
|
+
**Step 1: Add variant/value precedence tests**
|
|
238
|
+
|
|
239
|
+
Add inside the `describe ".create_evaluation_event"` block:
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
context "variant vs value precedence" do
|
|
243
|
+
it "uses variant when present and omits value" do
|
|
244
|
+
event = described_class.create_evaluation_event(
|
|
245
|
+
hook_context: hook_context,
|
|
246
|
+
evaluation_details: evaluation_details
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
expect(event.attributes).to have_key("feature_flag.result.variant")
|
|
250
|
+
expect(event.attributes).not_to have_key("feature_flag.result.value")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
it "uses value when variant is nil" do
|
|
254
|
+
resolution = OpenFeature::SDK::Provider::ResolutionDetails.new(
|
|
255
|
+
value: "blue",
|
|
256
|
+
reason: "STATIC"
|
|
257
|
+
)
|
|
258
|
+
details = OpenFeature::SDK::EvaluationDetails.new(
|
|
259
|
+
flag_key: "color-flag",
|
|
260
|
+
resolution_details: resolution
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
event = described_class.create_evaluation_event(
|
|
264
|
+
hook_context: hook_context,
|
|
265
|
+
evaluation_details: details
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
expect(event.attributes["feature_flag.result.value"]).to eq("blue")
|
|
269
|
+
expect(event.attributes).not_to have_key("feature_flag.result.variant")
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
**Step 2: Run tests**
|
|
275
|
+
|
|
276
|
+
Run: `bundle exec rspec spec/open_feature/sdk/telemetry_spec.rb`
|
|
277
|
+
Expected: PASS (3 examples, 0 failures)
|
|
278
|
+
|
|
279
|
+
**Step 3: Commit**
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
git add spec/open_feature/sdk/telemetry_spec.rb
|
|
283
|
+
git commit -s -S -m "test(telemetry): add variant vs value precedence tests"
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
### Task 5: Add tests for enum downcasing
|
|
289
|
+
|
|
290
|
+
**Files:**
|
|
291
|
+
- Modify: `spec/open_feature/sdk/telemetry_spec.rb`
|
|
292
|
+
|
|
293
|
+
**Step 1: Add enum downcasing tests**
|
|
294
|
+
|
|
295
|
+
Add inside the `describe ".create_evaluation_event"` block:
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
context "enum downcasing" do
|
|
299
|
+
it "downcases reason to OTel convention" do
|
|
300
|
+
event = described_class.create_evaluation_event(
|
|
301
|
+
hook_context: hook_context,
|
|
302
|
+
evaluation_details: evaluation_details
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
expect(event.attributes["feature_flag.result.reason"]).to eq("targeting_match")
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
it "downcases error_code to OTel convention" do
|
|
309
|
+
resolution = OpenFeature::SDK::Provider::ResolutionDetails.new(
|
|
310
|
+
value: false,
|
|
311
|
+
reason: "ERROR",
|
|
312
|
+
error_code: "FLAG_NOT_FOUND",
|
|
313
|
+
error_message: "Flag not found"
|
|
314
|
+
)
|
|
315
|
+
details = OpenFeature::SDK::EvaluationDetails.new(
|
|
316
|
+
flag_key: "missing-flag",
|
|
317
|
+
resolution_details: resolution
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
event = described_class.create_evaluation_event(
|
|
321
|
+
hook_context: hook_context,
|
|
322
|
+
evaluation_details: details
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
expect(event.attributes["error.type"]).to eq("flag_not_found")
|
|
326
|
+
expect(event.attributes["feature_flag.result.reason"]).to eq("error")
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
**Step 2: Run tests**
|
|
332
|
+
|
|
333
|
+
Run: `bundle exec rspec spec/open_feature/sdk/telemetry_spec.rb`
|
|
334
|
+
Expected: PASS (5 examples, 0 failures)
|
|
335
|
+
|
|
336
|
+
**Step 3: Commit**
|
|
337
|
+
|
|
338
|
+
```bash
|
|
339
|
+
git add spec/open_feature/sdk/telemetry_spec.rb
|
|
340
|
+
git commit -s -S -m "test(telemetry): add enum downcasing tests"
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
### Task 6: Add tests for error attributes and nil evaluation_details
|
|
346
|
+
|
|
347
|
+
**Files:**
|
|
348
|
+
- Modify: `spec/open_feature/sdk/telemetry_spec.rb`
|
|
349
|
+
|
|
350
|
+
**Step 1: Add error and nil evaluation_details tests**
|
|
351
|
+
|
|
352
|
+
Add inside the `describe ".create_evaluation_event"` block:
|
|
353
|
+
|
|
354
|
+
```ruby
|
|
355
|
+
context "error attributes" do
|
|
356
|
+
it "includes error attributes only when error occurred" do
|
|
357
|
+
resolution = OpenFeature::SDK::Provider::ResolutionDetails.new(
|
|
358
|
+
value: false,
|
|
359
|
+
reason: "ERROR",
|
|
360
|
+
error_code: "PARSE_ERROR",
|
|
361
|
+
error_message: "Could not parse flag"
|
|
362
|
+
)
|
|
363
|
+
details = OpenFeature::SDK::EvaluationDetails.new(
|
|
364
|
+
flag_key: "bad-flag",
|
|
365
|
+
resolution_details: resolution
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
event = described_class.create_evaluation_event(
|
|
369
|
+
hook_context: hook_context,
|
|
370
|
+
evaluation_details: details
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
expect(event.attributes["error.type"]).to eq("parse_error")
|
|
374
|
+
expect(event.attributes["error.message"]).to eq("Could not parse flag")
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
it "omits error attributes when no error" do
|
|
378
|
+
event = described_class.create_evaluation_event(
|
|
379
|
+
hook_context: hook_context,
|
|
380
|
+
evaluation_details: evaluation_details
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
expect(event.attributes).not_to have_key("error.type")
|
|
384
|
+
expect(event.attributes).not_to have_key("error.message")
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
context "nil evaluation_details" do
|
|
389
|
+
it "returns event with only flag_key and available context" do
|
|
390
|
+
event = described_class.create_evaluation_event(
|
|
391
|
+
hook_context: hook_context,
|
|
392
|
+
evaluation_details: nil
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
expect(event.name).to eq("feature_flag.evaluation")
|
|
396
|
+
expect(event.attributes).to eq(
|
|
397
|
+
"feature_flag.key" => "my-flag",
|
|
398
|
+
"feature_flag.provider.name" => "test-provider",
|
|
399
|
+
"feature_flag.context.id" => "user-123"
|
|
400
|
+
)
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
**Step 2: Run tests**
|
|
406
|
+
|
|
407
|
+
Run: `bundle exec rspec spec/open_feature/sdk/telemetry_spec.rb`
|
|
408
|
+
Expected: PASS (8 examples, 0 failures)
|
|
409
|
+
|
|
410
|
+
**Step 3: Commit**
|
|
411
|
+
|
|
412
|
+
```bash
|
|
413
|
+
git add spec/open_feature/sdk/telemetry_spec.rb
|
|
414
|
+
git commit -s -S -m "test(telemetry): add error attributes and nil evaluation_details tests"
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
419
|
+
### Task 7: Add tests for flag metadata and context ID precedence
|
|
420
|
+
|
|
421
|
+
**Files:**
|
|
422
|
+
- Modify: `spec/open_feature/sdk/telemetry_spec.rb`
|
|
423
|
+
|
|
424
|
+
**Step 1: Add metadata and context ID tests**
|
|
425
|
+
|
|
426
|
+
Add inside the `describe ".create_evaluation_event"` block:
|
|
427
|
+
|
|
428
|
+
```ruby
|
|
429
|
+
context "flag metadata" do
|
|
430
|
+
it "ignores nil flag_metadata" do
|
|
431
|
+
resolution = OpenFeature::SDK::Provider::ResolutionDetails.new(
|
|
432
|
+
value: true,
|
|
433
|
+
variant: "on"
|
|
434
|
+
)
|
|
435
|
+
details = OpenFeature::SDK::EvaluationDetails.new(
|
|
436
|
+
flag_key: "my-flag",
|
|
437
|
+
resolution_details: resolution
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
event = described_class.create_evaluation_event(
|
|
441
|
+
hook_context: hook_context,
|
|
442
|
+
evaluation_details: details
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
expect(event.attributes).not_to have_key("feature_flag.set.id")
|
|
446
|
+
expect(event.attributes).not_to have_key("feature_flag.version")
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
it "ignores empty flag_metadata" do
|
|
450
|
+
resolution = OpenFeature::SDK::Provider::ResolutionDetails.new(
|
|
451
|
+
value: true,
|
|
452
|
+
variant: "on",
|
|
453
|
+
flag_metadata: {}
|
|
454
|
+
)
|
|
455
|
+
details = OpenFeature::SDK::EvaluationDetails.new(
|
|
456
|
+
flag_key: "my-flag",
|
|
457
|
+
resolution_details: resolution
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
event = described_class.create_evaluation_event(
|
|
461
|
+
hook_context: hook_context,
|
|
462
|
+
evaluation_details: details
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
expect(event.attributes).not_to have_key("feature_flag.set.id")
|
|
466
|
+
expect(event.attributes).not_to have_key("feature_flag.version")
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
it "ignores unknown metadata keys" do
|
|
470
|
+
resolution = OpenFeature::SDK::Provider::ResolutionDetails.new(
|
|
471
|
+
value: true,
|
|
472
|
+
variant: "on",
|
|
473
|
+
flag_metadata: {"customKey" => "custom-value", "anotherKey" => 42}
|
|
474
|
+
)
|
|
475
|
+
details = OpenFeature::SDK::EvaluationDetails.new(
|
|
476
|
+
flag_key: "my-flag",
|
|
477
|
+
resolution_details: resolution
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
event = described_class.create_evaluation_event(
|
|
481
|
+
hook_context: hook_context,
|
|
482
|
+
evaluation_details: details
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
expect(event.attributes).not_to have_key("customKey")
|
|
486
|
+
expect(event.attributes).not_to have_key("anotherKey")
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
context "context ID precedence" do
|
|
491
|
+
it "uses metadata contextId over targeting_key" do
|
|
492
|
+
event = described_class.create_evaluation_event(
|
|
493
|
+
hook_context: hook_context,
|
|
494
|
+
evaluation_details: evaluation_details
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
# flag_metadata has contextId "ctx-456", targeting_key is "user-123"
|
|
498
|
+
expect(event.attributes["feature_flag.context.id"]).to eq("ctx-456")
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
it "falls back to targeting_key when no contextId in metadata" do
|
|
502
|
+
resolution = OpenFeature::SDK::Provider::ResolutionDetails.new(
|
|
503
|
+
value: true,
|
|
504
|
+
variant: "on",
|
|
505
|
+
flag_metadata: {"flagSetId" => "set-1"}
|
|
506
|
+
)
|
|
507
|
+
details = OpenFeature::SDK::EvaluationDetails.new(
|
|
508
|
+
flag_key: "my-flag",
|
|
509
|
+
resolution_details: resolution
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
event = described_class.create_evaluation_event(
|
|
513
|
+
hook_context: hook_context,
|
|
514
|
+
evaluation_details: details
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
expect(event.attributes["feature_flag.context.id"]).to eq("user-123")
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
it "omits context ID when neither targeting_key nor contextId available" do
|
|
521
|
+
bare_context = OpenFeature::SDK::EvaluationContext.new(env: "prod")
|
|
522
|
+
bare_hook_context = OpenFeature::SDK::Hooks::HookContext.new(
|
|
523
|
+
flag_key: "my-flag",
|
|
524
|
+
flag_value_type: :boolean,
|
|
525
|
+
default_value: false,
|
|
526
|
+
evaluation_context: bare_context
|
|
527
|
+
)
|
|
528
|
+
resolution = OpenFeature::SDK::Provider::ResolutionDetails.new(
|
|
529
|
+
value: true,
|
|
530
|
+
variant: "on"
|
|
531
|
+
)
|
|
532
|
+
details = OpenFeature::SDK::EvaluationDetails.new(
|
|
533
|
+
flag_key: "my-flag",
|
|
534
|
+
resolution_details: resolution
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
event = described_class.create_evaluation_event(
|
|
538
|
+
hook_context: bare_hook_context,
|
|
539
|
+
evaluation_details: details
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
expect(event.attributes).not_to have_key("feature_flag.context.id")
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
**Step 2: Run tests**
|
|
548
|
+
|
|
549
|
+
Run: `bundle exec rspec spec/open_feature/sdk/telemetry_spec.rb`
|
|
550
|
+
Expected: PASS (14 examples, 0 failures)
|
|
551
|
+
|
|
552
|
+
**Step 3: Commit**
|
|
553
|
+
|
|
554
|
+
```bash
|
|
555
|
+
git add spec/open_feature/sdk/telemetry_spec.rb
|
|
556
|
+
git commit -s -S -m "test(telemetry): add flag metadata and context ID precedence tests"
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
---
|
|
560
|
+
|
|
561
|
+
### Task 8: Run full suite and lint
|
|
562
|
+
|
|
563
|
+
**Step 1: Run full test suite**
|
|
564
|
+
|
|
565
|
+
Run: `bundle exec rspec`
|
|
566
|
+
Expected: All existing tests still pass, plus 14 new telemetry tests.
|
|
567
|
+
|
|
568
|
+
**Step 2: Run linter**
|
|
569
|
+
|
|
570
|
+
Run: `bundle exec standardrb`
|
|
571
|
+
Expected: No offenses. If any, fix with `bundle exec standardrb --fix`.
|
|
572
|
+
|
|
573
|
+
**Step 3: Final commit if lint fixes needed**
|
|
574
|
+
|
|
575
|
+
```bash
|
|
576
|
+
git add -A
|
|
577
|
+
git commit -s -S -m "style(telemetry): fix standardrb lint issues"
|
|
578
|
+
```
|
data/lib/open_feature/sdk/api.rb
CHANGED
|
@@ -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
|
|
@@ -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
|
|
@@ -70,27 +74,40 @@ module OpenFeature
|
|
|
70
74
|
def fetch_details(type:, flag_key:, default_value:, evaluation_context: nil, invocation_hooks: [], hook_hints: nil)
|
|
71
75
|
validate_default_value_type(type, default_value)
|
|
72
76
|
|
|
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
77
|
built_context = build_evaluation_context(evaluation_context)
|
|
86
78
|
|
|
87
79
|
# Assemble ordered hooks: API → Client → Invocation → Provider (spec 4.4.2)
|
|
88
80
|
provider_hooks = @provider.respond_to?(:hooks) ? Array(@provider.hooks) : []
|
|
89
81
|
ordered_hooks = [*OpenFeature::SDK.hooks, *@hooks, *invocation_hooks, *provider_hooks]
|
|
90
82
|
|
|
83
|
+
# Check for short-circuit conditions (spec 1.7.6 + 1.7.7)
|
|
84
|
+
short_circuit_code = nil
|
|
85
|
+
if OpenFeature::SDK.configuration.provider_tracked?(@provider)
|
|
86
|
+
short_circuit_code = short_circuit_error_code(provider_status)
|
|
87
|
+
end
|
|
88
|
+
|
|
91
89
|
# Fast path: skip hook ceremony when no hooks are registered
|
|
92
90
|
if ordered_hooks.empty?
|
|
93
|
-
|
|
91
|
+
if short_circuit_code
|
|
92
|
+
resolution = Provider::ResolutionDetails.new(
|
|
93
|
+
value: default_value,
|
|
94
|
+
error_code: short_circuit_code,
|
|
95
|
+
reason: Provider::Reason::ERROR
|
|
96
|
+
)
|
|
97
|
+
return EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution)
|
|
98
|
+
end
|
|
99
|
+
|
|
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
|
|
94
111
|
end
|
|
95
112
|
|
|
96
113
|
hook_context = Hooks::HookContext.new(
|
|
@@ -111,8 +128,21 @@ module OpenFeature
|
|
|
111
128
|
end
|
|
112
129
|
|
|
113
130
|
executor = Hooks::HookExecutor.new(logger: OpenFeature::SDK.configuration.logger)
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
116
146
|
end
|
|
117
147
|
end
|
|
118
148
|
|
|
@@ -128,6 +158,7 @@ module OpenFeature
|
|
|
128
158
|
resolution_details.value = default_value
|
|
129
159
|
resolution_details.error_code = Provider::ErrorCode::TYPE_MISMATCH
|
|
130
160
|
resolution_details.reason = Provider::Reason::ERROR
|
|
161
|
+
resolution_details.variant = nil
|
|
131
162
|
end
|
|
132
163
|
|
|
133
164
|
EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution_details)
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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:
|
|
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:
|
|
205
|
+
error_code: error_code,
|
|
177
206
|
original_error: e
|
|
178
207
|
)
|
|
179
208
|
end
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenFeature
|
|
4
|
+
module SDK
|
|
5
|
+
module Telemetry
|
|
6
|
+
EVENT_NAME = "feature_flag.evaluation"
|
|
7
|
+
|
|
8
|
+
FLAG_KEY = "feature_flag.key"
|
|
9
|
+
CONTEXT_ID_KEY = "feature_flag.context.id"
|
|
10
|
+
ERROR_MESSAGE_KEY = "error.message"
|
|
11
|
+
ERROR_TYPE_KEY = "error.type"
|
|
12
|
+
PROVIDER_NAME_KEY = "feature_flag.provider.name"
|
|
13
|
+
RESULT_REASON_KEY = "feature_flag.result.reason"
|
|
14
|
+
RESULT_VALUE_KEY = "feature_flag.result.value"
|
|
15
|
+
RESULT_VARIANT_KEY = "feature_flag.result.variant"
|
|
16
|
+
FLAG_SET_ID_KEY = "feature_flag.set.id"
|
|
17
|
+
VERSION_KEY = "feature_flag.version"
|
|
18
|
+
|
|
19
|
+
METADATA_KEY_MAP = {
|
|
20
|
+
"contextId" => CONTEXT_ID_KEY,
|
|
21
|
+
"flagSetId" => FLAG_SET_ID_KEY,
|
|
22
|
+
"version" => VERSION_KEY
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
EvaluationEvent = Struct.new(:name, :attributes, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def create_evaluation_event(hook_context:, evaluation_details:)
|
|
30
|
+
attributes = {FLAG_KEY => hook_context.flag_key}
|
|
31
|
+
|
|
32
|
+
provider_name = hook_context.provider_metadata&.name
|
|
33
|
+
attributes[PROVIDER_NAME_KEY] = provider_name if provider_name
|
|
34
|
+
|
|
35
|
+
targeting_key = hook_context.evaluation_context&.targeting_key
|
|
36
|
+
# Set from targeting_key; may be overridden by contextId in flag metadata below
|
|
37
|
+
attributes[CONTEXT_ID_KEY] = targeting_key if targeting_key
|
|
38
|
+
|
|
39
|
+
if evaluation_details
|
|
40
|
+
if evaluation_details.variant
|
|
41
|
+
attributes[RESULT_VARIANT_KEY] = evaluation_details.variant
|
|
42
|
+
else
|
|
43
|
+
attributes[RESULT_VALUE_KEY] = evaluation_details.value
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if evaluation_details.reason
|
|
47
|
+
attributes[RESULT_REASON_KEY] = evaluation_details.reason.downcase
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if evaluation_details.error_code
|
|
51
|
+
attributes[ERROR_TYPE_KEY] = evaluation_details.error_code.downcase
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if evaluation_details.error_message
|
|
55
|
+
attributes[ERROR_MESSAGE_KEY] = evaluation_details.error_message
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
extract_metadata(evaluation_details.flag_metadata, attributes)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
EvaluationEvent.new(name: EVENT_NAME, attributes: attributes.freeze)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def extract_metadata(flag_metadata, attributes)
|
|
65
|
+
return unless flag_metadata
|
|
66
|
+
|
|
67
|
+
METADATA_KEY_MAP.each do |metadata_key, otel_key|
|
|
68
|
+
value = flag_metadata[metadata_key]
|
|
69
|
+
attributes[otel_key] = value unless value.nil?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private_class_method :extract_metadata
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/open_feature/sdk.rb
CHANGED
data/renovate.json
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.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- OpenFeature Authors
|
|
@@ -159,6 +159,9 @@ files:
|
|
|
159
159
|
- LICENSE
|
|
160
160
|
- README.md
|
|
161
161
|
- Rakefile
|
|
162
|
+
- cucumber.yml
|
|
163
|
+
- docs/plans/2026-03-07-telemetry-utility-design.md
|
|
164
|
+
- docs/plans/2026-03-07-telemetry-utility-plan.md
|
|
162
165
|
- lib/open_feature/sdk.rb
|
|
163
166
|
- lib/open_feature/sdk/api.rb
|
|
164
167
|
- lib/open_feature/sdk/client.rb
|
|
@@ -186,6 +189,7 @@ files:
|
|
|
186
189
|
- lib/open_feature/sdk/provider_initialization_error.rb
|
|
187
190
|
- lib/open_feature/sdk/provider_state.rb
|
|
188
191
|
- lib/open_feature/sdk/provider_state_registry.rb
|
|
192
|
+
- lib/open_feature/sdk/telemetry.rb
|
|
189
193
|
- lib/open_feature/sdk/thread_local_transaction_context_propagator.rb
|
|
190
194
|
- lib/open_feature/sdk/tracking_event_details.rb
|
|
191
195
|
- lib/open_feature/sdk/transaction_context_propagator.rb
|