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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 304772054cf10c0cc02870fdeb7a1a7412342912e3f8a08d80c33fe25bd99849
4
- data.tar.gz: 6a27c2a8bf23074292594f4eec0ecd4dd9df11ddc1e3e48c4c7ff245e76a6381
3
+ metadata.gz: 4aa61c48f017d3311bfce9022a5e08d023ee1e2c09eb8799ff0e0a8424cf9fe3
4
+ data.tar.gz: 8c559636076a0ec01911e9057d57cde4e430a7bc4b2543835dcd90527e0f0c0e
5
5
  SHA512:
6
- metadata.gz: bcaacd882ecdf0ebd0643e1a839bae3d4efb02deb30f2128c5feca91c93e85bee65b755147cf4bef6edabc010875746b777cd4570f26161451a620db396e02e8
7
- data.tar.gz: 5a6ba82992f4d8e960d350a573e48a5e015c9b8eb953737354a4573d0a9f89ca6c4615553d0240a91c1490aad8fc3e1c2ca4bfeddc64d0b1203835ede55e1c77
6
+ metadata.gz: 0f683b0d315b225259e540fb9f7d86f60a7e334c1501b7f05d45d677d1e7ee6cfb951b463514c05d9c64b5b413935d1bdf95cd684aa5f05136ed87a6cf2ba122
7
+ data.tar.gz: 618dcac7749c461bb77b92f7f7b68e2071b4b71825d05330bb18a08df3a929019ef0fa8d12d79b117fdd3fce431d48f5e15d2f90aa884fa4f28b1226229f9db0
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.6.2"
2
+ ".": "0.6.4"
3
3
  }
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
@@ -4,3 +4,6 @@ source "https://rubygems.org"
4
4
 
5
5
  # Specify your gem's dependencies in openfeature-sdk.gemspec
6
6
  gemspec
7
+
8
+ gem "cucumber", "~> 10.0", group: :test
9
+ gem "logger", group: :test
data/Gemfile.lock CHANGED
@@ -1,12 +1,40 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- openfeature-sdk (0.6.2)
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.2">
21
- <img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.6.2&color=blue&style=for-the-badge" />
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
@@ -7,4 +7,9 @@ RSpec::Core::RakeTask.new(:spec)
7
7
 
8
8
  require "standard/rake"
9
9
 
10
+ desc "Run Cucumber Gherkin feature tests"
11
+ task :cucumber do
12
+ sh "bundle exec cucumber"
13
+ end
14
+
10
15
  task default: %i[spec standard]
data/cucumber.yml ADDED
@@ -0,0 +1,2 @@
1
+ default: spec/open-feature-spec/specification/assets/gherkin --require features --tags "not @deprecated" --publish-quiet
2
+ deprecated: spec/open-feature-spec/specification/assets/gherkin --require features --publish-quiet
@@ -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
+ ```
@@ -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
- return evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: built_context)
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
- executor.execute(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints) do |hctx|
115
- evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
131
+
132
+ if short_circuit_code
133
+ # Spec 1.7.6 + 1.7.7: short-circuit must still run error hooks and finally hooks
134
+ resolution = Provider::ResolutionDetails.new(
135
+ value: default_value,
136
+ error_code: short_circuit_code,
137
+ reason: Provider::Reason::ERROR
138
+ )
139
+ evaluation_details = EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution)
140
+ executor.run_short_circuit(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints, evaluation_details: evaluation_details)
141
+ evaluation_details
142
+ else
143
+ executor.execute(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints) do |hctx|
144
+ evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
145
+ end
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)
@@ -2,6 +2,8 @@
2
2
 
3
3
  module OpenFeature
4
4
  module SDK
5
- ClientMetadata = Struct.new(:domain, keyword_init: true)
5
+ ClientMetadata = Struct.new(:domain, keyword_init: true) do
6
+ alias_method :name, :domain
7
+ end
6
8
  end
7
9
  end
@@ -31,6 +31,10 @@ module OpenFeature
31
31
  @client_handlers_mutex = Mutex.new
32
32
  end
33
33
 
34
+ def add_hooks(*new_hooks)
35
+ @hooks.concat(new_hooks.flatten)
36
+ end
37
+
34
38
  def provider(domain: nil)
35
39
  @providers[domain] || @providers[nil]
36
40
  end
@@ -85,9 +89,11 @@ module OpenFeature
85
89
  end
86
90
 
87
91
  def shutdown
88
- providers_to_shutdown = @provider_mutex.synchronize { @providers.values.uniq }
92
+ providers_to_shutdown = @provider_mutex.synchronize { @providers.values.uniq(&:object_id) }
89
93
 
90
94
  providers_to_shutdown.each do |prov|
95
+ # Spec 1.7.9: Set provider state to NOT_READY before shutdown
96
+ @provider_state_registry.set_initial_state(prov, ProviderState::NOT_READY)
91
97
  prov.shutdown if prov.respond_to?(:shutdown)
92
98
  rescue => e
93
99
  @logger&.warn("Error shutting down provider #{prov&.class&.name || "unknown"}: #{e.message}")
@@ -107,34 +113,46 @@ module OpenFeature
107
113
  @provider_mutex.synchronize do
108
114
  @providers.clear
109
115
  end
116
+ @hooks.clear
117
+ @evaluation_context = nil
118
+ @transaction_context_propagator = nil
110
119
  end
111
120
 
112
121
  def set_provider_internal(provider, domain:, wait_for_init:)
113
122
  # Capture evaluation context before acquiring mutex to prevent race conditions
114
123
  context_for_init = @evaluation_context
115
124
 
116
- old_provider, provider_to_init = nil
125
+ old_provider = nil
126
+ needs_init = false
127
+ needs_shutdown = false
117
128
 
118
129
  @provider_mutex.synchronize do
119
130
  old_provider = @providers[domain]
120
131
 
121
- # Remove old provider state to prevent memory leaks
122
- @provider_state_registry.remove_provider(old_provider)
123
-
124
132
  new_providers = @providers.dup
125
133
  new_providers[domain] = provider
126
134
  @providers = new_providers
127
135
 
128
- @provider_state_registry.set_initial_state(provider)
136
+ # Spec 1.1.2.2: Only initialize if the provider is not already active
137
+ # (i.e., not already bound to another domain)
138
+ already_active = @providers.any? { |d, p| d != domain && p.equal?(provider) && @provider_state_registry.tracked?(p) }
139
+ needs_init = !already_active
129
140
 
130
- provider.send(:attach, self) if provider.is_a?(Provider::EventEmitter)
141
+ if needs_init
142
+ @provider_state_registry.set_initial_state(provider)
143
+ provider.send(:attach, self) if provider.is_a?(Provider::EventEmitter)
144
+ end
131
145
 
132
- provider_to_init = provider
146
+ # Spec 1.1.2.3: Only shutdown old provider if it's no longer bound to any domain
147
+ if old_provider && !old_provider.equal?(provider)
148
+ still_bound = @providers.any? { |_, p| p.equal?(old_provider) }
149
+ needs_shutdown = !still_bound
150
+ @provider_state_registry.remove_provider(old_provider) unless still_bound
151
+ end
133
152
  end
134
153
 
135
154
  # Shutdown old provider outside mutex to avoid blocking other operations
136
- # Only shutdown if it's a different provider to prevent race condition
137
- if old_provider && old_provider != provider
155
+ if needs_shutdown
138
156
  begin
139
157
  old_provider.shutdown if old_provider.respond_to?(:shutdown)
140
158
  rescue => e
@@ -143,12 +161,17 @@ module OpenFeature
143
161
  end
144
162
 
145
163
  # Initialize provider outside the mutex to avoid blocking other operations
146
- if wait_for_init
147
- init_provider(provider_to_init, context_for_init, raise_on_error: true)
148
- else
149
- Thread.new do
150
- init_provider(provider_to_init, context_for_init, raise_on_error: false)
164
+ if needs_init
165
+ if wait_for_init
166
+ init_provider(provider, context_for_init, raise_on_error: true)
167
+ else
168
+ Thread.new do
169
+ init_provider(provider, context_for_init, raise_on_error: false)
170
+ end
151
171
  end
172
+ elsif wait_for_init
173
+ # Provider already active; no init needed but still dispatch READY
174
+ dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY)
152
175
  end
153
176
  end
154
177
 
@@ -164,16 +187,22 @@ module OpenFeature
164
187
 
165
188
  dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY)
166
189
  rescue => e
190
+ # Spec 1.7.8: Propagate error code from provider if available
191
+ error_code = if e.respond_to?(:error_code) && e.error_code
192
+ e.error_code
193
+ else
194
+ Provider::ErrorCode::GENERAL
195
+ end
196
+
167
197
  dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR,
168
- error_code: Provider::ErrorCode::GENERAL,
198
+ error_code: error_code,
169
199
  message: e.message)
170
200
 
171
201
  if raise_on_error
172
- # Re-raise as ProviderInitializationError for synchronous callers
173
202
  raise ProviderInitializationError.new(
174
203
  "Provider #{provider.class.name} initialization failed: #{e.message}",
175
204
  provider:,
176
- error_code: Provider::ErrorCode::GENERAL,
205
+ error_code: error_code,
177
206
  original_error: e
178
207
  )
179
208
  end
@@ -18,6 +18,20 @@ module OpenFeature
18
18
  @logger = logger
19
19
  end
20
20
 
21
+ # Runs error hooks and finally hooks for a short-circuit evaluation
22
+ # (spec 1.7.6 + 1.7.7). Before hooks and after hooks are NOT run.
23
+ #
24
+ # @param ordered_hooks [Array] hooks in before-order
25
+ # @param hook_context [HookContext] the hook context
26
+ # @param hints [Hints] hook hints
27
+ # @param evaluation_details [EvaluationDetails] the short-circuit result
28
+ def run_short_circuit(ordered_hooks:, hook_context:, hints:, evaluation_details:)
29
+ error = StandardError.new(evaluation_details.error_code)
30
+ run_error_hooks(ordered_hooks, hook_context, error, hints)
31
+ ensure
32
+ run_finally_hooks(ordered_hooks, hook_context, evaluation_details, hints)
33
+ end
34
+
21
35
  # Executes the full hook lifecycle around the flag evaluation block.
22
36
  #
23
37
  # @param ordered_hooks [Array] hooks in before-order (API, Client, Invocation, Provider)
@@ -31,7 +45,15 @@ module OpenFeature
31
45
  begin
32
46
  run_before_hooks(ordered_hooks, hook_context, hints)
33
47
  evaluation_details = evaluate_block.call(hook_context)
34
- run_after_hooks(ordered_hooks, hook_context, evaluation_details, hints)
48
+
49
+ # Spec 4.3.6: If evaluation resulted in an error (e.g. FLAG_NOT_FOUND,
50
+ # TYPE_MISMATCH), run error hooks instead of after hooks.
51
+ if evaluation_details.error_code
52
+ error = StandardError.new(evaluation_details.error_message || evaluation_details.error_code)
53
+ run_error_hooks(ordered_hooks, hook_context, error, hints)
54
+ else
55
+ run_after_hooks(ordered_hooks, hook_context, evaluation_details, hints)
56
+ end
35
57
  rescue => e
36
58
  run_error_hooks(ordered_hooks, hook_context, e, hints)
37
59
 
@@ -29,8 +29,10 @@ module OpenFeature
29
29
  end
30
30
 
31
31
  def update_flags(new_flags)
32
+ old_keys = @flags.keys
32
33
  @flags = new_flags.dup
33
- emit_provider_changed(new_flags.keys)
34
+ changed_keys = (old_keys | new_flags.keys)
35
+ emit_provider_changed(changed_keys)
34
36
  end
35
37
 
36
38
  def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
@@ -0,0 +1,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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OpenFeature
4
4
  module SDK
5
- VERSION = "0.6.2"
5
+ VERSION = "0.6.4"
6
6
  end
7
7
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "sdk/version"
4
4
  require_relative "sdk/api"
5
+ require_relative "sdk/telemetry"
5
6
  require_relative "sdk/transaction_context_propagator"
6
7
  require_relative "sdk/thread_local_transaction_context_propagator"
7
8
 
data/renovate.json CHANGED
@@ -3,7 +3,8 @@
3
3
  "extends": [
4
4
  "config:recommended",
5
5
  ":automergeStableNonMajor",
6
- "npm:unpublishSafe"
6
+ "security:minimumReleaseAgeNpm"
7
7
  ],
8
- "semanticCommits": "enabled"
8
+ "semanticCommits": "enabled",
9
+ "platformAutomerge": true
9
10
  }
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.2
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