Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
73 changes: 72 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,77 @@ 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,
namespace_key: str | 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
namespace_key: The namespace to evaluate the flag in

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 namespace_key is not None:
warnings.warn(
"namespace_key parameter is deprecated and will be ignored",
DeprecationWarning,
stacklevel=2,
)
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
98 changes: 97 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,102 @@ 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,
namespace_key: str | None = None,
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.
namespace_key: The namespace key of the feature flag.
If None, reads UNSTRACT_FEATURE_FLAG_NAMESPACE env var,
falling back to "default".
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