From a43597dc09f48395642ed1550039fcf43b5ea035 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:42:57 +0000 Subject: [PATCH] feat: Populate OpenFeature flag metadata from the evaluation reason Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com> --- README.md | 18 +++++++++ ld_openfeature/impl/details_converter.py | 48 +++++++++++++++++++++--- tests/impl/test_details_converter.py | 42 +++++++++++++++++++++ 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e511c13..f829dd8 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,24 @@ attributes = { context = EvaluationContext(null, attributes) ``` +### Flag Metadata + +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. + +| Key | Type | Description | +|---------------------|---------|-------------------------------------------------------------------| +| `variationIndex` | integer | The index of the returned variation. Absent for default values. | +| `inExperiment` | boolean | Present, and `True`, when the evaluation was part of an experiment.| +| `ruleIndex` | integer | The index of the rule that matched. | +| `ruleId` | string | The identifier of the rule that matched. | +| `prerequisiteKey` | string | The key of the prerequisite flag that failed. | +| `bigSegmentsStatus` | string | The status of the Big Segments query made during the evaluation. | + +```python +details = client.get_boolean_details("my-flag", False, context) +in_experiment = details.flag_metadata.get("inExperiment", False) +``` + ## Learn more 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). diff --git a/ld_openfeature/impl/details_converter.py b/ld_openfeature/impl/details_converter.py index bed586f..bcf428a 100644 --- a/ld_openfeature/impl/details_converter.py +++ b/ld_openfeature/impl/details_converter.py @@ -1,8 +1,19 @@ -from typing import Optional +from typing import Any, Dict, Mapping, Optional, Union from ldclient.evaluation import EvaluationDetail from openfeature.exception import ErrorCode -from openfeature.flag_evaluation import FlagResolutionDetails, Reason +from openfeature.flag_evaluation import ( + FlagMetadata, + FlagResolutionDetails, + Reason, +) + +_VARIATION_INDEX_KEY = 'variationIndex' +_IN_EXPERIMENT_KEY = 'inExperiment' +_RULE_INDEX_KEY = 'ruleIndex' +_RULE_ID_KEY = 'ruleId' +_PREREQUISITE_KEY_KEY = 'prerequisiteKey' +_BIG_SEGMENTS_STATUS_KEY = 'bigSegmentsStatus' class ResolutionDetailsConverter: @@ -30,10 +41,37 @@ def to_resolution_details(self, result: EvaluationDetail) -> FlagResolutionDetai error_code=openfeature_error_code, error_message=None, reason=openfeature_reason, - variant=openfeature_variant - # flag_metadata = FlagMetadata = field(default_factory=dict) + variant=openfeature_variant, + flag_metadata=self.__to_flag_metadata(reason, variation_index if not is_default else None), ) - pass + + @staticmethod + def __to_flag_metadata(reason: Mapping[str, Any], variation_index: Optional[int]) -> FlagMetadata: + metadata: Dict[str, Union[bool, int, float, str]] = {} + + if variation_index is not None: + metadata[_VARIATION_INDEX_KEY] = variation_index + + if reason.get('inExperiment') is True: + metadata[_IN_EXPERIMENT_KEY] = True + + rule_index = reason.get('ruleIndex') + if isinstance(rule_index, int): + metadata[_RULE_INDEX_KEY] = rule_index + + rule_id = reason.get('ruleId') + if isinstance(rule_id, str): + metadata[_RULE_ID_KEY] = rule_id + + prerequisite_key = reason.get('prerequisiteKey') + if isinstance(prerequisite_key, str): + metadata[_PREREQUISITE_KEY_KEY] = prerequisite_key + + big_segments_status = reason.get('bigSegmentsStatus') + if isinstance(big_segments_status, str): + metadata[_BIG_SEGMENTS_STATUS_KEY] = big_segments_status + + return metadata @staticmethod def __kind_to_reason(kind: str) -> str: diff --git a/tests/impl/test_details_converter.py b/tests/impl/test_details_converter.py index f0addda..19fffb1 100644 --- a/tests/impl/test_details_converter.py +++ b/tests/impl/test_details_converter.py @@ -46,3 +46,45 @@ def test_ld_to_openfeature_error_kind_mappings(error_kind: Optional[str], error_ resolution_details = details_converter.to_resolution_details(detail) assert resolution_details.reason == Reason.ERROR assert resolution_details.error_code == error_code + + +def test_flag_metadata_includes_the_variation_index(details_converter: ResolutionDetailsConverter): + detail = EvaluationDetail(True, 1, {'kind': 'FALLTHROUGH'}) + resolution_details = details_converter.to_resolution_details(detail) + assert resolution_details.flag_metadata == {'variationIndex': 1} + + +def test_flag_metadata_omits_the_variation_index_for_default_values(details_converter: ResolutionDetailsConverter): + detail = EvaluationDetail(True, None, {'kind': 'ERROR', 'errorKind': 'FLAG_NOT_FOUND'}) + resolution_details = details_converter.to_resolution_details(detail) + assert resolution_details.flag_metadata == {} + + +def test_flag_metadata_includes_in_experiment_for_experiment_evaluations(details_converter: ResolutionDetailsConverter): + detail = EvaluationDetail(True, 1, {'kind': 'FALLTHROUGH', 'inExperiment': True}) + resolution_details = details_converter.to_resolution_details(detail) + assert resolution_details.flag_metadata == {'variationIndex': 1, 'inExperiment': True} + + +def test_flag_metadata_omits_in_experiment_for_non_experiment_evaluations(details_converter: ResolutionDetailsConverter): + detail = EvaluationDetail(True, 1, {'kind': 'FALLTHROUGH', 'inExperiment': False}) + resolution_details = details_converter.to_resolution_details(detail) + assert 'inExperiment' not in resolution_details.flag_metadata + + +def test_flag_metadata_includes_the_rule_for_rule_matches(details_converter: ResolutionDetailsConverter): + detail = EvaluationDetail(True, 1, {'kind': 'RULE_MATCH', 'ruleIndex': 2, 'ruleId': 'the-rule-id'}) + resolution_details = details_converter.to_resolution_details(detail) + assert resolution_details.flag_metadata == {'variationIndex': 1, 'ruleIndex': 2, 'ruleId': 'the-rule-id'} + + +def test_flag_metadata_includes_the_prerequisite_key(details_converter: ResolutionDetailsConverter): + detail = EvaluationDetail(True, 1, {'kind': 'PREREQUISITE_FAILED', 'prerequisiteKey': 'the-prerequisite-key'}) + resolution_details = details_converter.to_resolution_details(detail) + assert resolution_details.flag_metadata == {'variationIndex': 1, 'prerequisiteKey': 'the-prerequisite-key'} + + +def test_flag_metadata_includes_the_big_segments_status(details_converter: ResolutionDetailsConverter): + detail = EvaluationDetail(True, 1, {'kind': 'FALLTHROUGH', 'bigSegmentsStatus': 'STALE'}) + resolution_details = details_converter.to_resolution_details(detail) + assert resolution_details.flag_metadata == {'variationIndex': 1, 'bigSegmentsStatus': 'STALE'}