Skip to content
10 changes: 3 additions & 7 deletions docker/docker-compose-dev-essentials.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
3 changes: 0 additions & 3 deletions docker/sample.essentials.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 64 additions & 1 deletion unstract/flags/src/unstract/flags/client/flipt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand All @@ -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.

Expand Down
94 changes: 93 additions & 1 deletion unstract/flags/src/unstract/flags/feature_flag.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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