Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 3.2.0 - 2024-01-09

1. Numeric property handling for feature flags now does the expected: When passed in a number, we do a numeric comparison. When passed in a string, we do a string comparison. Previously, we always did a string comparison.
2. Add support for relative date operators for local evaluation.

## 3.1.0 - 2023-12-04

1. Increase maximum event size and batch size
Expand Down
108 changes: 83 additions & 25 deletions posthog/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
import hashlib
import logging
import re
from typing import Optional

from dateutil import parser
from dateutil.relativedelta import relativedelta

from posthog.utils import convert_to_datetime_aware, is_valid_regex

Expand Down Expand Up @@ -117,15 +119,17 @@ def match_property(property, property_values) -> bool:

override_value = property_values[key]

if operator == "exact":
if isinstance(value, list):
return override_value in value
return value == override_value
if operator in ("exact", "is_not"):

if operator == "is_not":
if isinstance(value, list):
return override_value not in value
return value != override_value
def compute_exact_match(value, override_value):
if isinstance(value, list):
return str(override_value).lower() in [str(val).lower() for val in value]
return str(value).lower() == str(override_value).lower()

if operator == "exact":
return compute_exact_match(value, override_value)
else:
return not compute_exact_match(value, override_value)

if operator == "is_set":
return key in property_values
Expand All @@ -142,41 +146,64 @@ def match_property(property, property_values) -> bool:
if operator == "not_regex":
return is_valid_regex(str(value)) and re.compile(str(value)).search(str(override_value)) is None

if operator == "gt":
return type(override_value) is type(value) and override_value > value

if operator == "gte":
return type(override_value) is type(value) and override_value >= value
if operator in ("gt", "gte", "lt", "lte"):
# :TRICKY: We adjust comparison based on the override value passed in,
# to make sure we handle both numeric and string comparisons appropriately.
def compare(lhs, rhs, operator):
if operator == "gt":
return lhs > rhs
elif operator == "gte":
return lhs >= rhs
elif operator == "lt":
return lhs < rhs
elif operator == "lte":
return lhs <= rhs
else:
raise ValueError(f"Invalid operator: {operator}")

if operator == "lt":
return type(override_value) is type(value) and override_value < value
parsed_value = None
try:
parsed_value = float(value) # type: ignore
except Exception:
pass

if operator == "lte":
return type(override_value) is type(value) and override_value <= value
if parsed_value is not None and override_value is not None:
if isinstance(override_value, str):
return compare(override_value, str(value), operator)
else:
return compare(override_value, parsed_value, operator)
else:
return compare(str(override_value), str(value), operator)

if operator in ["is_date_before", "is_date_after"]:
if operator in ["is_date_before", "is_date_after", "is_relative_date_before", "is_relative_date_after"]:
try:
parsed_date = parser.parse(value)
parsed_date = convert_to_datetime_aware(parsed_date)
except Exception:
if operator in ["is_relative_date_before", "is_relative_date_after"]:
parsed_date = relative_date_parse_for_feature_flag_matching(str(value))
else:
parsed_date = parser.parse(str(value))
parsed_date = convert_to_datetime_aware(parsed_date)
except Exception as e:
raise InconclusiveMatchError("The date set on the flag is not a valid format") from e

if not parsed_date:
raise InconclusiveMatchError("The date set on the flag is not a valid format")

if isinstance(override_value, datetime.datetime):
override_date = convert_to_datetime_aware(override_value)
if operator == "is_date_before":
if operator in ("is_date_before", "is_relative_date_before"):
return override_date < parsed_date
else:
return override_date > parsed_date
elif isinstance(override_value, datetime.date):
if operator == "is_date_before":
if operator in ("is_date_before", "is_relative_date_before"):
return override_value < parsed_date.date()
else:
return override_value > parsed_date.date()
elif isinstance(override_value, str):
try:
override_date = parser.parse(override_value)
override_date = convert_to_datetime_aware(override_date)
if operator == "is_date_before":
if operator in ("is_date_before", "is_relative_date_before"):
return override_date < parsed_date
else:
return override_date > parsed_date
Expand All @@ -185,7 +212,8 @@ def match_property(property, property_values) -> bool:
else:
raise InconclusiveMatchError("The date provided must be a string or date object")

return False
# if we get here, we don't know how to handle the operator
raise InconclusiveMatchError(f"Unknown operator {operator}")


def match_cohort(property, property_values, cohort_properties) -> bool:
Expand Down Expand Up @@ -271,3 +299,33 @@ def match_property_group(property_group, property_values, cohort_properties) ->

# if we get here, all matched in AND case, or none matched in OR case
return property_group_type == "AND"


def relative_date_parse_for_feature_flag_matching(value: str) -> Optional[datetime.datetime]:
regex = r"^(?P<number>[0-9]+)(?P<interval>[a-z])$"
match = re.search(regex, value)
parsed_dt = datetime.datetime.now(datetime.timezone.utc)
if match:
number = int(match.group("number"))

if number >= 10_000:
# Guard against overflow, disallow numbers greater than 10_000
return None

interval = match.group("interval")
if interval == "h":
parsed_dt = parsed_dt - relativedelta(hours=number)
elif interval == "d":
parsed_dt = parsed_dt - relativedelta(days=number)
elif interval == "w":
parsed_dt = parsed_dt - relativedelta(weeks=number)
elif interval == "m":
parsed_dt = parsed_dt - relativedelta(months=number)
elif interval == "y":
parsed_dt = parsed_dt - relativedelta(years=number)
else:
return None

return parsed_dt
else:
return None
Loading