diff --git a/docker/docker-compose-dev-essentials.yaml b/docker/docker-compose-dev-essentials.yaml index e5b308eefc..d0ec7b4ba2 100644 --- a/docker/docker-compose-dev-essentials.yaml +++ b/docker/docker-compose-dev-essentials.yaml @@ -92,18 +92,14 @@ services: - "host.docker.internal:host-gateway" feature-flag: - image: flipt/flipt:v1.34.0 # Dated(05/01/2024) Latest stable version. Ref:https://github.com/flipt-io/flipt/releases + image: docker.flipt.io/flipt/flipt:v2.3.1 container_name: unstract-flipt restart: unless-stopped ports: # Forwarded to available host ports - "8082:8080" # REST API port - "9005:9000" # gRPC port - # https://www.flipt.io/docs/configuration/overview#environment-variables) - # https://www.flipt.io/docs/configuration/overview#configuration-parameters - env_file: - - ./essentials.env - environment: - FLIPT_CACHE_ENABLED: true + volumes: + - flipt_data:/var/opt/flipt labels: - traefik.enable=true - traefik.http.routers.feature-flag.rule=Host(`feature-flag.unstract.localhost`) diff --git a/docker/sample.essentials.env b/docker/sample.essentials.env index e5adb46786..51876fb8f9 100644 --- a/docker/sample.essentials.env +++ b/docker/sample.essentials.env @@ -10,9 +10,6 @@ MINIO_ROOT_PASSWORD=minio123 MINIO_ACCESS_KEY=minio MINIO_SECRET_KEY=minio123 -# Use encoded password Refer : https://docs.sqlalchemy.org/en/20/core/engines.html#escaping-special-characters-such-as-signs-in-passwords -FLIPT_DB_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable" - QDRANT_USER=unstract_vector_dev QDRANT_PASS=unstract_vector_pass QDRANT_DB=unstract_vector_db diff --git a/unstract/flags/src/unstract/flags/client/flipt.py b/unstract/flags/src/unstract/flags/client/flipt.py index fa2910c913..23c5f64df3 100644 --- a/unstract/flags/src/unstract/flags/client/flipt.py +++ b/unstract/flags/src/unstract/flags/client/flipt.py @@ -68,7 +68,7 @@ def evaluate_boolean( flag_key=flag_key, entity_id=entity_id, context=context or {} ) - return result.value if hasattr(result, "value") else False + return result.enabled if hasattr(result, "enabled") else False except Exception as e: logger.error(f"Error evaluating flag {flag_key} for entity {entity_id}: {e}") @@ -81,6 +81,69 @@ def evaluate_boolean( except Exception as e: logger.error(f"Error closing Flipt client: {e}") + def evaluate_variant( + self, + flag_key: str, + entity_id: str | None = "unstract", + context: dict | None = None, + ) -> dict: + """Evaluate a variant feature flag for a given entity. + + Args: + flag_key: The key of the feature flag to evaluate + entity_id: The ID of the entity for which to evaluate the flag + context: Additional context for evaluation + + Returns: + dict: {"match": bool, "variant_key": str, "variant_attachment": str, + "segment_keys": list[str]} + Returns empty dict with match=False if service unavailable or error. + """ + default_result = { + "match": False, + "variant_key": "", + "variant_attachment": "", + "segment_keys": [], + } + if not self.service_available: + logger.warning("Flipt service not available, returning default for all flags") + return default_result + + client = None + try: + client = FliptGrpcClient(opts=self.grpc_opts) + + result = client.evaluate_variant( + flag_key=flag_key, entity_id=entity_id, context=context or {} + ) + + return { + "match": result.match if hasattr(result, "match") else False, + "variant_key": ( + result.variant_key if hasattr(result, "variant_key") else "" + ), + "variant_attachment": ( + result.variant_attachment + if hasattr(result, "variant_attachment") + else "" + ), + "segment_keys": ( + list(result.segment_keys) if hasattr(result, "segment_keys") else [] + ), + } + + except Exception as e: + logger.error( + f"Error evaluating variant flag {flag_key} for entity {entity_id}: {e}" + ) + return default_result + finally: + if client: + try: + client.close() + except Exception as e: + logger.error(f"Error closing Flipt client: {e}") + def list_feature_flags(self, namespace_key: str | None = None) -> dict: """List all feature flags in a namespace. diff --git a/unstract/flags/src/unstract/flags/feature_flag.py b/unstract/flags/src/unstract/flags/feature_flag.py index 4776f5bd29..f9833c3aa2 100644 --- a/unstract/flags/src/unstract/flags/feature_flag.py +++ b/unstract/flags/src/unstract/flags/feature_flag.py @@ -42,6 +42,98 @@ def check_feature_flag_status( context=context or {}, ) - return bool(result.enabled) + return bool(result) except Exception: return False + + +def check_feature_flag_variant( + flag_key: str, + entity_id: str = "unstract", + context: dict[str, str] | None = None, +) -> dict: + """Check a variant feature flag and return its evaluation result. + + Evaluates a Flipt variant flag and returns the full evaluation response. + The function first checks whether the flag is enabled before calling + Flipt's variant evaluation API. + + Args: + flag_key: The flag key of the feature flag. + entity_id: An identifier for the evaluation entity. Used by Flipt + for consistent percentage-based rollout hashing only — it does + NOT influence segment constraint matching. + context: Key-value pairs matched against Flipt segment constraints. + Keys must correspond exactly to the constraint property names + configured in Flipt. For example, if a segment has a constraint + on property "organization_id", pass + ``{"organization_id": "org_123"}``. Defaults to None. + + Returns: + dict with the following fields: + + - **enabled** (bool): Whether the flag is enabled in Flipt. + - **match** (bool): Whether the entity matched a segment rule. + - **variant_key** (str): The key of the matched variant (empty + string if no match). + - **variant_attachment** (str): JSON string attached to the variant + (empty string if no match). Parse with ``json.loads()`` to get + structured data. + - **segment_keys** (list[str]): Segment keys that were matched. + + Result interpretation: + - ``enabled=False`` → Flag is disabled or not found in Flipt. + All other fields are at their defaults. + - ``enabled=True, match=True`` → The entity's context matched a + segment rule and a variant was assigned. ``variant_key`` and + ``variant_attachment`` contain the assigned values. + - ``enabled=True, match=False`` → The flag is on but no segment + rule matched the provided context. This typically means Flipt + is missing Segments and/or Rules for this flag, or the context + keys/values don't satisfy any segment constraint. + + Note: + Variant flags in Flipt require three things to be configured for + ``match=True``: **Variants** (the possible values), **Segments** + (constraint-based groups), and **Rules** (which link segments to + variants). If any of these are missing, evaluation returns + ``match=False``. + + Example:: + + import json + + result = check_feature_flag_variant( + flag_key="extraction_engine", + context={"organization_id": "org_123"}, + ) + if result["enabled"] and result["match"]: + config = json.loads(result["variant_attachment"]) + engine = config["engine"] + """ + default_result = { + "enabled": False, + "match": False, + "variant_key": "", + "variant_attachment": "", + "segment_keys": [], + } + try: + client = FliptClient() + + # Check enabled status first + flags = client.list_feature_flags() + if not flags.get("flags", {}).get(flag_key, False): + return default_result + + # Flag is enabled, evaluate variant + result = client.evaluate_variant( + flag_key=flag_key, + entity_id=entity_id, + context=context or {}, + ) + result["enabled"] = True + + return result + except Exception: + return default_result diff --git a/unstract/flags/src/unstract/flags/flipt_grpc/flipt/__init__.py b/unstract/flags/src/unstract/flags/flipt_grpc/flipt/__init__.py index 2fd9f1a7b1..55a203097f 100644 --- a/unstract/flags/src/unstract/flags/flipt_grpc/flipt/__init__.py +++ b/unstract/flags/src/unstract/flags/flipt_grpc/flipt/__init__.py @@ -1 +1,10 @@ """Flipt gRPC protobuf definitions.""" + +# Pre-register the Timestamp well-known type in the protobuf descriptor pool. +# Required because flipt_simple_pb2.py and evaluation_simple_pb2.py declare +# a dependency on google/protobuf/timestamp.proto in their serialized descriptors. +# Without this, AddSerializedFile() fails with KeyError (pure-Python) or TypeError (C/upb). +# In the backend, this happens to work because Google Cloud libraries (google-cloud-storage, +# etc.) import timestamp_pb2 as a side effect during Django startup. Workers don't have +# that implicit dependency, so we must be explicit. +from google.protobuf import timestamp_pb2 as _timestamp_pb2 # noqa: F401