Skip to content

Commit 11ae1a6

Browse files
feat: Populate OpenFeature flag metadata from the evaluation reason (#53)
Populates OpenFeature flag metadata with the LaunchDarkly specific parts of the evaluation result, so hooks (including the OpenTelemetry hook) can read them. - Keys mirror the evaluation reason fields: `variationIndex`, `inExperiment`, `ruleIndex`, `ruleId`, `prerequisiteKey`, `bigSegmentsStatus`. - Each key is omitted rather than default-valued when it does not apply, so a consumer can distinguish "not in an experiment" from "no information". - Matches [openfeature-java-server#56](launchdarkly/openfeature-java-server#56) and [openfeature-dotnet-server#61](launchdarkly/openfeature-dotnet-server#61); the naming is being defined in an [sdk-specs spec](launchdarkly/sdk-specs#253). No screenshots or staging preview apply - this is a server-side library change with no UI. <details> <summary>Implementation details</summary> **Requirements** - [x] I have added test coverage for new or changed functionality - [x] I have followed the repository's pull request submission guidelines - [x] I have validated my changes against all supported platform versions **Describe the solution you've provided** `ResolutionDetailsConverter` now builds `flag_metadata` from the LaunchDarkly reason dictionary: ```python flag_metadata=self.__to_flag_metadata(reason, variation_index if not is_default else None) ``` Each reason field is read defensively with an `isinstance` check, since the reason is a plain dictionary, so a malformed reason cannot put an unexpected type into the metadata. `variationIndex` uses the same default-value check that already gates `variant`. The README gains a "Flag Metadata" section documenting the keys. **Describe alternatives you've considered** Passing the whole reason dictionary through as a single `reason` metadata entry would be closer to the LaunchDarkly shape, but `FlagMetadata` values are scalars, so the flat keys are the only available shape. **Additional context** ``` poetry run pytest poetry run mypy ld_openfeature tests ``` All tests pass, including 7 new cases covering each key and the omissions. `make test`/`make lint` currently fail on `poetry install` on `main` as well, because `poetry.lock` is out of date relative to `pyproject.toml` - unrelated to this change. </details> @cursor review Link to Devin session: https://app.devin.ai/sessions/0c452d209ec54b068ba120b4c92b8f6c Requested by: @kinyoklion <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > OpenFeature evaluation details now include LaunchDarkly-specific fields on `flag_metadata`, so hooks (including OpenTelemetry) can read experiment, rule, and related evaluation info that has no OpenFeature equivalent. > > `ResolutionDetailsConverter` maps `variationIndex`, `inExperiment`, `ruleIndex`, `ruleId`, `prerequisiteKey`, and `bigSegmentsStatus` from the LD reason. Keys are omitted when they do not apply (including `inExperiment` when false, and `variationIndex` for default values). README documents the keys. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a43597d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 15014c1 commit 11ae1a6

3 files changed

Lines changed: 103 additions & 5 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,24 @@ attributes = {
140140
context = EvaluationContext(null, attributes)
141141
```
142142

143+
### Flag Metadata
144+
145+
Evaluations include flag metadata for the LaunchDarkly specific parts of the evaluation result which have no OpenFeature equivalent. Each entry is absent when it does not apply to the evaluation.
146+
147+
| Key | Type | Description |
148+
|---------------------|---------|-------------------------------------------------------------------|
149+
| `variationIndex` | integer | The index of the returned variation. Absent for default values. |
150+
| `inExperiment` | boolean | Present, and `True`, when the evaluation was part of an experiment.|
151+
| `ruleIndex` | integer | The index of the rule that matched. |
152+
| `ruleId` | string | The identifier of the rule that matched. |
153+
| `prerequisiteKey` | string | The key of the prerequisite flag that failed. |
154+
| `bigSegmentsStatus` | string | The status of the Big Segments query made during the evaluation. |
155+
156+
```python
157+
details = client.get_boolean_details("my-flag", False, context)
158+
in_experiment = details.flag_metadata.get("inExperiment", False)
159+
```
160+
143161
## Learn more
144162

145163
Check out our [documentation](http://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. You can also head straight to the [complete reference guide for this SDK](https://docs.launchdarkly.com/sdk/server-side/python).

ld_openfeature/impl/details_converter.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
1-
from typing import Optional
1+
from typing import Any, Dict, Mapping, Optional, Union
22

33
from ldclient.evaluation import EvaluationDetail
44
from openfeature.exception import ErrorCode
5-
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
5+
from openfeature.flag_evaluation import (
6+
FlagMetadata,
7+
FlagResolutionDetails,
8+
Reason,
9+
)
10+
11+
_VARIATION_INDEX_KEY = 'variationIndex'
12+
_IN_EXPERIMENT_KEY = 'inExperiment'
13+
_RULE_INDEX_KEY = 'ruleIndex'
14+
_RULE_ID_KEY = 'ruleId'
15+
_PREREQUISITE_KEY_KEY = 'prerequisiteKey'
16+
_BIG_SEGMENTS_STATUS_KEY = 'bigSegmentsStatus'
617

718

819
class ResolutionDetailsConverter:
@@ -30,10 +41,37 @@ def to_resolution_details(self, result: EvaluationDetail) -> FlagResolutionDetai
3041
error_code=openfeature_error_code,
3142
error_message=None,
3243
reason=openfeature_reason,
33-
variant=openfeature_variant
34-
# flag_metadata = FlagMetadata = field(default_factory=dict)
44+
variant=openfeature_variant,
45+
flag_metadata=self.__to_flag_metadata(reason, variation_index if not is_default else None),
3546
)
36-
pass
47+
48+
@staticmethod
49+
def __to_flag_metadata(reason: Mapping[str, Any], variation_index: Optional[int]) -> FlagMetadata:
50+
metadata: Dict[str, Union[bool, int, float, str]] = {}
51+
52+
if variation_index is not None:
53+
metadata[_VARIATION_INDEX_KEY] = variation_index
54+
55+
if reason.get('inExperiment') is True:
56+
metadata[_IN_EXPERIMENT_KEY] = True
57+
58+
rule_index = reason.get('ruleIndex')
59+
if isinstance(rule_index, int):
60+
metadata[_RULE_INDEX_KEY] = rule_index
61+
62+
rule_id = reason.get('ruleId')
63+
if isinstance(rule_id, str):
64+
metadata[_RULE_ID_KEY] = rule_id
65+
66+
prerequisite_key = reason.get('prerequisiteKey')
67+
if isinstance(prerequisite_key, str):
68+
metadata[_PREREQUISITE_KEY_KEY] = prerequisite_key
69+
70+
big_segments_status = reason.get('bigSegmentsStatus')
71+
if isinstance(big_segments_status, str):
72+
metadata[_BIG_SEGMENTS_STATUS_KEY] = big_segments_status
73+
74+
return metadata
3775

3876
@staticmethod
3977
def __kind_to_reason(kind: str) -> str:

tests/impl/test_details_converter.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,45 @@ def test_ld_to_openfeature_error_kind_mappings(error_kind: Optional[str], error_
4646
resolution_details = details_converter.to_resolution_details(detail)
4747
assert resolution_details.reason == Reason.ERROR
4848
assert resolution_details.error_code == error_code
49+
50+
51+
def test_flag_metadata_includes_the_variation_index(details_converter: ResolutionDetailsConverter):
52+
detail = EvaluationDetail(True, 1, {'kind': 'FALLTHROUGH'})
53+
resolution_details = details_converter.to_resolution_details(detail)
54+
assert resolution_details.flag_metadata == {'variationIndex': 1}
55+
56+
57+
def test_flag_metadata_omits_the_variation_index_for_default_values(details_converter: ResolutionDetailsConverter):
58+
detail = EvaluationDetail(True, None, {'kind': 'ERROR', 'errorKind': 'FLAG_NOT_FOUND'})
59+
resolution_details = details_converter.to_resolution_details(detail)
60+
assert resolution_details.flag_metadata == {}
61+
62+
63+
def test_flag_metadata_includes_in_experiment_for_experiment_evaluations(details_converter: ResolutionDetailsConverter):
64+
detail = EvaluationDetail(True, 1, {'kind': 'FALLTHROUGH', 'inExperiment': True})
65+
resolution_details = details_converter.to_resolution_details(detail)
66+
assert resolution_details.flag_metadata == {'variationIndex': 1, 'inExperiment': True}
67+
68+
69+
def test_flag_metadata_omits_in_experiment_for_non_experiment_evaluations(details_converter: ResolutionDetailsConverter):
70+
detail = EvaluationDetail(True, 1, {'kind': 'FALLTHROUGH', 'inExperiment': False})
71+
resolution_details = details_converter.to_resolution_details(detail)
72+
assert 'inExperiment' not in resolution_details.flag_metadata
73+
74+
75+
def test_flag_metadata_includes_the_rule_for_rule_matches(details_converter: ResolutionDetailsConverter):
76+
detail = EvaluationDetail(True, 1, {'kind': 'RULE_MATCH', 'ruleIndex': 2, 'ruleId': 'the-rule-id'})
77+
resolution_details = details_converter.to_resolution_details(detail)
78+
assert resolution_details.flag_metadata == {'variationIndex': 1, 'ruleIndex': 2, 'ruleId': 'the-rule-id'}
79+
80+
81+
def test_flag_metadata_includes_the_prerequisite_key(details_converter: ResolutionDetailsConverter):
82+
detail = EvaluationDetail(True, 1, {'kind': 'PREREQUISITE_FAILED', 'prerequisiteKey': 'the-prerequisite-key'})
83+
resolution_details = details_converter.to_resolution_details(detail)
84+
assert resolution_details.flag_metadata == {'variationIndex': 1, 'prerequisiteKey': 'the-prerequisite-key'}
85+
86+
87+
def test_flag_metadata_includes_the_big_segments_status(details_converter: ResolutionDetailsConverter):
88+
detail = EvaluationDetail(True, 1, {'kind': 'FALLTHROUGH', 'bigSegmentsStatus': 'STALE'})
89+
resolution_details = details_converter.to_resolution_details(detail)
90+
assert resolution_details.flag_metadata == {'variationIndex': 1, 'bigSegmentsStatus': 'STALE'}

0 commit comments

Comments
 (0)