From 79152727db00720e5fd5258d9bf1463dd0cd2501 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 22 Oct 2026 20:30:59 +0200 Subject: [PATCH 1/4] fix(flags): fall back for incompatible local regex evaluation --- .../changesets/local-regex-compatibility.md | 5 + posthog/feature_flags.py | 169 +++++++++++- posthog/test/test_feature_flags_regex.py | 246 ++++++++++++++++++ 3 files changed, 414 insertions(+), 6 deletions(-) create mode 100644 .sampo/changesets/local-regex-compatibility.md create mode 100644 posthog/test/test_feature_flags_regex.py diff --git a/.sampo/changesets/local-regex-compatibility.md b/.sampo/changesets/local-regex-compatibility.md new file mode 100644 index 000000000..5bba25a54 --- /dev/null +++ b/.sampo/changesets/local-regex-compatibility.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Defer feature flag regex patterns with unsupported syntax or incompatible matching semantics to server evaluation instead of returning incorrect local results. Preserve portable Unicode matching and make end-of-input anchors strict, matching the flags service. diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 049e2fe4e..d6e5d7107 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -8,11 +8,12 @@ import warnings from decimal import Decimal from enum import Enum -from typing import Optional +from typing import NoReturn, Optional from posthog import utils as utils from posthog.types import FlagValue -from posthog.utils import convert_to_datetime_aware, is_valid_regex +from posthog.utils import convert_to_datetime_aware +from posthog.utils import is_valid_regex as is_valid_regex __LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF) @@ -660,6 +661,162 @@ def _parse_numeric_value(value) -> float | None: return parsed if math.isfinite(parsed) else None +def _match_regex(pattern: str, value: str) -> Optional[bool]: + """Return None for invalid shared syntax; raise inconclusive for unsupported cases.""" + if pattern == "": + return True + + # Successful re compilation alone does not establish Rust/fancy-regex compatibility. + translated = [] + class_start = None + previous = "" + uses_word_class = False + uses_space_class = False + index = 0 + + def defer() -> NoReturn: + raise InconclusiveMatchError("Regex requires server evaluation") + + while index < len(pattern): + character = pattern[index] + if 0xD800 <= ord(character) <= 0xDFFF: + defer() + if character == "\\": + index += 1 + if index == len(pattern): + translated.append("\\") + break + escaped = pattern[index] + if escaped == "x": + digits = pattern[index + 1 : index + 3] + if len(digits) != 2 or any( + c not in "0123456789abcdefABCDEF" for c in digits + ): + defer() + translated.append("\\x" + digits) + index += 3 + previous = "literal" + continue + if escaped in "bB<>" and class_start is None: + # Even short ASCII word-boundary searches can exceed the service's step limit. + defer() + if escaped not in "dDsSwWbfnrt": + if not (0x20 <= ord(escaped) <= 0x7E and not escaped.isalnum()): + defer() + if class_start is not None and escaped not in r"\-[]^.$*+?(){}|": + defer() + uses_word_class |= escaped in "wW" + uses_space_class |= escaped in "sS" + translated.append("\\" + escaped) + previous = "literal" + elif character == "[": + if class_start is not None: + defer() + class_start = index + translated.append(character) + elif character == "]" and class_start is not None: + content_start = class_start + ( + 2 if pattern[class_start + 1 : class_start + 2] == "^" else 1 + ) + if index == content_start: + defer() + class_start = None + previous = "literal" + translated.append(character) + elif class_start is not None: + if pattern[index : index + 2] in ("&&", "--", "~~"): + defer() + # Rust and Python disagree on shorthand endpoints in ranges. + if ( + character == "-" + and index + 1 < len(pattern) + and pattern[index + 1] != "]" + ): + if pattern[index + 1 : index + 3] in ( + r"\d", + r"\D", + r"\s", + r"\S", + r"\w", + r"\W", + ) or ( + index >= 2 + and pattern[index - 2] == "\\" + and pattern[index - 1] in "dDsSwW" + ): + defer() + translated.append(character) + elif character == "(" and pattern[index + 1 : index + 2] == "?": + if pattern[index + 2 : index + 3] != ":": + defer() + translated.append("(?:") + index += 2 + previous = "(" + elif character == "{": + repetition = re.match(r"\{([0-9]+)(?:,([0-9]*))?\}", pattern[index:]) + if repetition is None or previous in ( + "", + "|", + "(", + "^", + "$", + "empty group", + "quantifier", + ): + defer() + if any( + len(n) > 10 or int(n) > 0xFFFFFFFF for n in repetition.groups() if n + ): + defer() + translated.append(repetition.group()) + index += len(repetition.group()) - 1 + if pattern[index + 1 : index + 2] in ("+", "{"): + defer() + previous = "quantifier" + else: + if character in "*+?": + if previous in ("^", "$", "empty group") or pattern[ + index + 1 : index + 2 + ] in ("+", "{"): + defer() + previous = "quantifier" + else: + previous = ( + "empty group" + if character == ")" and previous in ("(", "empty group") + else character + ) + # Python's $ also matches before a final newline; Rust's default $ is strict. + translated.append(r"\Z" if character == "$" else character) + index += 1 + + try: + regex = re.compile("".join(translated)) + except re.error: + return None + except Exception as error: + raise InconclusiveMatchError( + "Regex compilation requires server evaluation" + ) from error + + # Unicode letters/decimal digits agree, but Rust's word class also includes marks and + # joiners, while Python includes other numbers. Python's whitespace adds U+001C..U+001F. + if any(0xD800 <= ord(c) <= 0xDFFF for c in value): + defer() + if uses_word_class and any( + not c.isascii() and not (c.isalpha() or c.isdecimal()) for c in value + ): + defer() + if uses_space_class and any(c in "\x1c\x1d\x1e\x1f" for c in value): + defer() + try: + return regex.search(value) is not None + except Exception as error: + raise InconclusiveMatchError( + "Regex execution requires server evaluation" + ) from error + + def match_property( property, property_values, property_matching_version: int = 1 ) -> bool: @@ -752,11 +909,11 @@ def compute_exact_match(value, override_value): return matched if operator in ("regex", "not_regex"): - if not is_valid_regex(str(value)): - return False property_string = "null" if override_value is None else str(override_value) - matched = re.compile(str(value)).search(property_string) is not None - return not matched if operator == "not_regex" else matched + regex_match = _match_regex(str(value), property_string) + if regex_match is None: + return False + return not regex_match if operator == "not_regex" else regex_match if operator in ["is_date_before", "is_date_after"]: try: diff --git a/posthog/test/test_feature_flags_regex.py b/posthog/test/test_feature_flags_regex.py new file mode 100644 index 000000000..3cb77b565 --- /dev/null +++ b/posthog/test/test_feature_flags_regex.py @@ -0,0 +1,246 @@ +"""Regex compatibility with the flags service's Rust matcher, not just Python re.""" + +from unittest import mock + +import pytest + +from posthog.client import Client +from posthog.feature_flags import ( + InconclusiveMatchError, + match_cohort, + match_property, + match_property_group, +) +from posthog.test.test_utils import FAKE_TEST_API_KEY + + +pytestmark = pytest.mark.filterwarnings( + r"ignore:`get_feature_flag` is deprecated:DeprecationWarning" +) + + +# These constructs either have different native semantics or require service syntax/budgets. +DEFERRED = [ + (r"^\p{L}+$", "é"), + (r"^\p{L}+$", None), + (r"^\P{L}+$", "123"), + (r"(?<=a+)b", "aab"), + (r"a(?=b)", "ab"), + (r"(a)\1", "aa"), + (r"(?i)^foo$", "FOO"), + (r"(?m)^foo$", "foo\nbar"), + (r"\Afoo\z", "foo"), + (r"^[a&&b]$", "a"), + (r"^[a-z&&[^aeiou]]+$", "bcdf"), + (r"^[a-z--b]$", "b"), + (r"^[a~~b]$", "~"), + (r"^[[:alpha:]]+$", "foo"), + (r"[]", "a"), + (r"[^]", "a"), + (r"\141", "a"), + (r"\q", "q"), + (r"^\x{41}$", "A"), + (r"a++", "aa"), + (r"a{,2}", "aa"), + (r"a*{2}", "aa"), + (r"{foo}", "{foo}"), + (r"[\s-9]", "-"), + (r"[a-\d]", "-"), + (r"^?https?://", "https://a"), + (r"$?", "x"), + (r"\b+", "x"), + (r"{2}", "x"), + (r"a|{2}", "x"), + (r"({2})", "x"), + (r"(?:{2})", "x"), + (r"(?:)*", "x"), + (r"()+", "x"), + (r"(?:){2}", "x"), + (r"a{0,4294967296}", "x"), + (r"a{4294967296,}", "x"), + (r".*\bbot\b.*", "a" * 140), + (r".*\Bbot\B.*", "a" * 140), + (r"\", "foo"), + (r"[\@]", "@"), + (r"[\B]", "B"), + (r"^\w+$", "\u0301"), # Rust includes combining marks; Python does not. + (r"^\W+$", "²"), # Python includes non-decimal numbers; Rust does not. + (r"^\s$", "\x1c"), # Python's extra ASCII whitespace is not Rust whitespace. + (r"^\S$", "\x1f"), + ("^.$", "\ud800"), + ("\ud800", "x"), +] + +LOCAL = [ + ("pro", "a pro plan", True), + ("^pro$", "a pro plan", False), + ("^pro$", "PRO", False), + (r"^\d+$", "123", True), + (r"^\d+$", "١", True), + (r"^\D+$", "é", True), + (r"^\w+$", "abc_123", True), + (r"^\w+$", "é١", True), + (r"^\W+$", "é١", False), + (r"^\s$", "\u2003", True), + (r"^\s$", "\ufeff", False), + ("^.$", "😀", True), + ("^é+$", "éé", True), + ("^.$", "\r", True), + (r"^[\w.+-]+@example\.com$", "a.b+tag@example.com", True), + (r"^(?:ab|cd){2,3}$", "abcdab", True), + (r"^\\p\{L\}$", r"\p{L}", True), + (r"^\(\?i\)$", "(?i)", True), + (r"^\x41+$", "AA", True), + (r"^nu", None, True), + (r"^\++$", "++", True), + (r"[\b]", "\b", True), + (r"^a\-b$", "a-b", True), + (r"\@example\.com$", "a@example.com", True), + (r"^a\:b$", "a:b", True), + (r"^a\_b$", "a_b", True), + (r"^a\=b$", "a=b", True), + (r"^a\ b$", "a b", True), + (r"^\^?$", "", True), + (r"^\${2}$", "$$", True), + (r"^\({2}$", "((", True), + (r"^\|{2}$", "||", True), + (r"^[\^$?]+$", "$?^", True), + (r"^[\w-]+$", "a-b", True), + ("", "", True), + ("", "é", True), + ("", None, True), + ("", "\r", True), + ("^foo$", "foo\n", False), + ("^foo$", "foo\nbar", False), + ("foo$", "foo\r\n", False), +] + + +def property_filter(pattern, operator): + return {"key": "value", "type": "person", "operator": operator, "value": pattern} + + +@pytest.mark.parametrize("operator", ["regex", "not_regex"]) +@pytest.mark.parametrize("pattern,value", DEFERRED) +def test_match_property_regex_defers_unsupported(pattern, value, operator): + with pytest.raises(InconclusiveMatchError): + match_property(property_filter(pattern, operator), {"value": value}) + + +@pytest.mark.parametrize("operator", ["regex", "not_regex"]) +@pytest.mark.parametrize("pattern,value,expected", LOCAL) +def test_match_property_regex_keeps_portable_patterns_local( + pattern, value, expected, operator +): + assert match_property(property_filter(pattern, operator), {"value": value}) is ( + expected if operator == "regex" else not expected + ) + + +@pytest.mark.parametrize("operator", ["regex", "not_regex"]) +@pytest.mark.parametrize("pattern", ["[", "(", "*", "?*"]) +@pytest.mark.parametrize("value", ["value", "é", "\r"]) +def test_match_property_regex_invalid_is_false_for_both_operators( + pattern, value, operator +): + assert match_property(property_filter(pattern, operator), {"value": value}) is False + + +@pytest.mark.parametrize("operator", ["regex", "not_regex"]) +def test_match_property_regex_compile_resource_failure_is_inconclusive(operator): + with pytest.raises(InconclusiveMatchError): + match_property( + property_filter("(" * 1000 + "a" + ")" * 1000, operator), {"value": "a"} + ) + + +@pytest.mark.parametrize("operator", ["regex", "not_regex"]) +@pytest.mark.parametrize("negation", [False, True]) +def test_match_property_regex_cohort_does_not_negate_inconclusive(operator, negation): + prop = {**property_filter(r"\p{L}+", operator), "negation": negation} + with pytest.raises(InconclusiveMatchError): + match_property_group({"type": "AND", "values": [prop]}, {"value": "é"}, {}) + + +@pytest.mark.parametrize("operator", ["regex", "not_regex"]) +@pytest.mark.parametrize( + "pattern,value", [(r"\p{L}+", "é"), (r".*\bbot\b.*", "a" * 140)] +) +def test_match_cohort_regex_not_in_does_not_negate_inconclusive( + operator, pattern, value +): + cohorts = {"1": {"type": "AND", "values": [property_filter(pattern, operator)]}} + with pytest.raises(InconclusiveMatchError): + match_cohort( + {"value": 1, "operator": "not_in", "type": "cohort"}, + {"value": value}, + cohorts, + ) + + +@pytest.fixture +def client(): + instance = Client(FAKE_TEST_API_KEY, send=False) + yield instance + instance.shutdown() + + +@pytest.mark.parametrize("api", ["evaluate_flags", "get_feature_flag"]) +@pytest.mark.parametrize("operator", ["regex", "not_regex"]) +def test_regex_public_fallback_boundaries(client, api, operator): + cases = [(p, {"value": v}, None) for p, v in DEFERRED] + cases += [ + (p, {"value": v}, expected if operator == "regex" else not expected) + for p, v, expected in LOCAL + ] + cases += [("[", {"value": "x"}, False), ("safe", {}, None)] + with ( + mock.patch("posthog.client.flags") as remote, + mock.patch.object(Client, "capture"), + ): + remote.return_value = { + "flags": {"regex-flag": {"enabled": True, "variant": None}} + } + for pattern, properties, local_result in cases: + client.feature_flags = [ + { + "id": 1, + "key": "regex-flag", + "active": True, + "filters": { + "groups": [ + { + "properties": [property_filter(pattern, operator)], + "rollout_percentage": 100, + } + ] + }, + } + ] + for local_only in (False, True): + remote.reset_mock() + kwargs = { + "person_properties": properties, + "only_evaluate_locally": local_only, + } + if api == "evaluate_flags": + result = client.evaluate_flags("user-1", **kwargs).get_flag( + "regex-flag" + ) + else: + result = client.get_feature_flag( + "regex-flag", "user-1", send_feature_flag_events=False, **kwargs + ) + expected = ( + (None if local_only else True) + if local_result is None + else local_result + ) + assert result is expected, ( + api, + operator, + pattern, + properties, + local_only, + ) + assert remote.call_count == int(local_result is None and not local_only) From 1a130878fc13ff0a83ecd717bf80320ee98dd902 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 23 Oct 2026 09:51:12 +0200 Subject: [PATCH 2/4] fix(flags): defer nested regex quantifiers and fast-path ASCII values --- posthog/feature_flags.py | 32 +++++++++++++++----- posthog/test/test_feature_flags_regex.py | 38 ++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index d6e5d7107..a3b7029c7 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -670,6 +670,8 @@ def _match_regex(pattern: str, value: str) -> Optional[bool]: translated = [] class_start = None previous = "" + group_has_quantifier = [] + closed_group_has_quantifier = False uses_word_class = False uses_space_class = False index = 0 @@ -681,6 +683,21 @@ def defer() -> NoReturn: character = pattern[index] if 0xD800 <= ord(character) <= 0xDFFF: defer() + if class_start is None: + if character == "(": + group_has_quantifier.append(False) + elif character == ")": + closed_group_has_quantifier = ( + group_has_quantifier.pop() if group_has_quantifier else False + ) + if group_has_quantifier: + group_has_quantifier[-1] |= closed_group_has_quantifier + elif character in "*+?{": + # Strict anchors can turn nested repetition into expensive backtracking. + if previous == ")" and closed_group_has_quantifier: + defer() + if group_has_quantifier: + group_has_quantifier[-1] = True if character == "\\": index += 1 if index == len(pattern): @@ -801,13 +818,14 @@ def defer() -> NoReturn: # Unicode letters/decimal digits agree, but Rust's word class also includes marks and # joiners, while Python includes other numbers. Python's whitespace adds U+001C..U+001F. - if any(0xD800 <= ord(c) <= 0xDFFF for c in value): - defer() - if uses_word_class and any( - not c.isascii() and not (c.isalpha() or c.isdecimal()) for c in value - ): - defer() - if uses_space_class and any(c in "\x1c\x1d\x1e\x1f" for c in value): + if not value.isascii(): + if any(0xD800 <= ord(c) <= 0xDFFF for c in value): + defer() + if uses_word_class and any( + not c.isascii() and not (c.isalpha() or c.isdecimal()) for c in value + ): + defer() + if uses_space_class and any(c in value for c in "\x1c\x1d\x1e\x1f"): defer() try: return regex.search(value) is not None diff --git a/posthog/test/test_feature_flags_regex.py b/posthog/test/test_feature_flags_regex.py index 3cb77b565..fdd5476e4 100644 --- a/posthog/test/test_feature_flags_regex.py +++ b/posthog/test/test_feature_flags_regex.py @@ -69,6 +69,22 @@ (r"^\S$", "\x1f"), ("^.$", "\ud800"), ("\ud800", "x"), + (r"^(a+)+$", "aa\n"), + (r"^(a+)+$", "aa"), + (r"^(?:ab*)+$", "ab"), + (r"^(a{1,2}){2}$", "aa"), + (r"^(a?b)*$", "ab"), + (r"^(a+|b)+$", "a"), + (r"^((a+))+$", "aa"), + (r"^((a+)b)+$", "ab"), + (r"^((a+)*b)+$", "aab"), + (r"^(a+)+?$", "aa"), + (r"(a+)+", "aa"), + (r"^\s$", "\x1d"), + (r"^\S$", "\x1e"), + (r"^\s+$", "\u2003\x1c"), + (r"^\w+$", "a\u0301"), + ("^a.$", "a\ud800"), ] LOCAL = [ @@ -113,6 +129,18 @@ ("^foo$", "foo\n", False), ("^foo$", "foo\nbar", False), ("foo$", "foo\r\n", False), + (r"^([+*?])+$", "+*?", True), + (r"^(\++)$", "++", True), + (r"^(\+)+$", "++", True), + (r"^\(+a+\)+$", "(aa)", True), + (r"^(a+)(b+)$", "aabb", True), + (r"^((ab)){2}$", "abab", True), + (r"^([()]+)$", "()", True), + (r"^(a)+(b)+$", "aabb", True), + (r"^((a)+b)$", "aab", True), + (r"^(a+)(?:b)+$", "aabb", True), + ("", "\u0301", True), + ("^$", "", True), ] @@ -156,15 +184,19 @@ def test_match_property_regex_compile_resource_failure_is_inconclusive(operator) @pytest.mark.parametrize("operator", ["regex", "not_regex"]) @pytest.mark.parametrize("negation", [False, True]) -def test_match_property_regex_cohort_does_not_negate_inconclusive(operator, negation): - prop = {**property_filter(r"\p{L}+", operator), "negation": negation} +@pytest.mark.parametrize("pattern", [r"\p{L}+", r"(é+)+"]) +def test_match_property_regex_cohort_does_not_negate_inconclusive( + operator, negation, pattern +): + prop = {**property_filter(pattern, operator), "negation": negation} with pytest.raises(InconclusiveMatchError): match_property_group({"type": "AND", "values": [prop]}, {"value": "é"}, {}) @pytest.mark.parametrize("operator", ["regex", "not_regex"]) @pytest.mark.parametrize( - "pattern,value", [(r"\p{L}+", "é"), (r".*\bbot\b.*", "a" * 140)] + "pattern,value", + [(r"\p{L}+", "é"), (r".*\bbot\b.*", "a" * 140), (r"^(a+)+$", "aa\n")], ) def test_match_cohort_regex_not_in_does_not_negate_inconclusive( operator, pattern, value From 7f996537fe9de8381570858bda70225bfe1d2181 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 23 Oct 2026 10:06:11 +0200 Subject: [PATCH 3/4] fix(flags): defer strict-end searches with final newline backtracking --- posthog/feature_flags.py | 8 ++++++ posthog/test/test_feature_flags_regex.py | 31 +++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index a3b7029c7..ca8cd65ec 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -674,6 +674,8 @@ def _match_regex(pattern: str, value: str) -> Optional[bool]: closed_group_has_quantifier = False uses_word_class = False uses_space_class = False + uses_end_anchor = False + uses_quantifier_or_alternation = False index = 0 def defer() -> NoReturn: @@ -684,6 +686,8 @@ def defer() -> NoReturn: if 0xD800 <= ord(character) <= 0xDFFF: defer() if class_start is None: + uses_end_anchor |= character == "$" + uses_quantifier_or_alternation |= character in "*+?{|" if character == "(": group_has_quantifier.append(False) elif character == ")": @@ -816,6 +820,10 @@ def defer() -> NoReturn: "Regex compilation requires server evaluation" ) from error + # Tightening $ can expose backtracking on a final LF, including repeated alternatives. + if uses_end_anchor and uses_quantifier_or_alternation and value.endswith("\n"): + defer() + # Unicode letters/decimal digits agree, but Rust's word class also includes marks and # joiners, while Python includes other numbers. Python's whitespace adds U+001C..U+001F. if not value.isascii(): diff --git a/posthog/test/test_feature_flags_regex.py b/posthog/test/test_feature_flags_regex.py index fdd5476e4..a7deee5f1 100644 --- a/posthog/test/test_feature_flags_regex.py +++ b/posthog/test/test_feature_flags_regex.py @@ -85,6 +85,14 @@ (r"^\s+$", "\u2003\x1c"), (r"^\w+$", "a\u0301"), ("^a.$", "a\ud800"), + (r"^(a|aa)+$", "aa\n"), + (r"^a+$", "aa\n"), + (r"^a*$", "a\n"), + (r"^a?$", "a\n"), + (r"^a{2}$", "aa\n"), + (r"^(?:ab|cd){2,3}$", "abcd\n"), + (r"^(?:foo|bar)$", "foo\n"), + (r"foo|bar$", "foo\n"), ] LOCAL = [ @@ -141,6 +149,14 @@ (r"^(a+)(?:b)+$", "aabb", True), ("", "\u0301", True), ("^$", "", True), + (r"^[+*?{}|]$", "+\n", False), + (r"^\+$", "+\n", False), + (r"^\|$", "|\n", False), + (r"^a\{$", "a{\n", False), + (r"^a+\$", "aa$\n", True), + (r"^foo\$$", "foo$\n", False), + (r"^a+", "aa\n", True), + (r"^(?:a|aa)+$", "aa", True), ] @@ -174,6 +190,14 @@ def test_match_property_regex_invalid_is_false_for_both_operators( assert match_property(property_filter(pattern, operator), {"value": value}) is False +@pytest.mark.parametrize("operator", ["regex", "not_regex"]) +@pytest.mark.parametrize("pattern", [r"*foo$", r"(a|b$"]) +def test_match_property_regex_invalid_precedes_final_newline_policy(pattern, operator): + assert ( + match_property(property_filter(pattern, operator), {"value": "foo\n"}) is False + ) + + @pytest.mark.parametrize("operator", ["regex", "not_regex"]) def test_match_property_regex_compile_resource_failure_is_inconclusive(operator): with pytest.raises(InconclusiveMatchError): @@ -225,7 +249,12 @@ def test_regex_public_fallback_boundaries(client, api, operator): (p, {"value": v}, expected if operator == "regex" else not expected) for p, v, expected in LOCAL ] - cases += [("[", {"value": "x"}, False), ("safe", {}, None)] + cases += [ + ("[", {"value": "x"}, False), + (r"*foo$", {"value": "foo\n"}, False), + (r"(a|b$", {"value": "foo\n"}, False), + ("safe", {}, None), + ] with ( mock.patch("posthog.client.flags") as remote, mock.patch.object(Client, "capture"), From c88e07ba173b52681c4b39ee112b295ed9182a57 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 23 Oct 2026 11:34:44 +0200 Subject: [PATCH 4/4] perf(flags): cache pure regex pattern metadata --- posthog/feature_flags.py | 44 ++++++++- posthog/test/test_feature_flags_regex.py | 111 +++++++++++++++++++++++ 2 files changed, 150 insertions(+), 5 deletions(-) diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index ca8cd65ec..66af600c7 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -8,7 +8,8 @@ import warnings from decimal import Decimal from enum import Enum -from typing import NoReturn, Optional +from functools import lru_cache as _lru_cache +from typing import NamedTuple, NoReturn, Optional from posthog import utils as utils from posthog.types import FlagValue @@ -661,11 +662,17 @@ def _parse_numeric_value(value) -> float | None: return parsed if math.isfinite(parsed) else None -def _match_regex(pattern: str, value: str) -> Optional[bool]: - """Return None for invalid shared syntax; raise inconclusive for unsupported cases.""" - if pattern == "": - return True +class _RegexMetadata(NamedTuple): + regex: re.Pattern[str] + uses_word_class: bool + uses_space_class: bool + uses_end_anchor: bool + uses_quantifier_or_alternation: bool + +@_lru_cache(maxsize=256) +def _compile_regex(pattern: str) -> Optional[_RegexMetadata]: + """Cache only pattern metadata; exceptions are not retained by lru_cache.""" # Successful re compilation alone does not establish Rust/fancy-regex compatibility. translated = [] class_start = None @@ -820,6 +827,33 @@ def defer() -> NoReturn: "Regex compilation requires server evaluation" ) from error + return _RegexMetadata( + regex, + uses_word_class, + uses_space_class, + uses_end_anchor, + uses_quantifier_or_alternation, + ) + + +def _match_regex(pattern: str, value: str) -> Optional[bool]: + """Return None for invalid shared syntax; raise inconclusive for unsupported cases.""" + if pattern == "": + return True + metadata = _compile_regex(pattern) + if metadata is None: + return None + ( + regex, + uses_word_class, + uses_space_class, + uses_end_anchor, + uses_quantifier_or_alternation, + ) = metadata + + def defer() -> NoReturn: + raise InconclusiveMatchError("Regex requires server evaluation") + # Tightening $ can expose backtracking on a final LF, including repeated alternatives. if uses_end_anchor and uses_quantifier_or_alternation and value.endswith("\n"): defer() diff --git a/posthog/test/test_feature_flags_regex.py b/posthog/test/test_feature_flags_regex.py index a7deee5f1..5819aacc6 100644 --- a/posthog/test/test_feature_flags_regex.py +++ b/posthog/test/test_feature_flags_regex.py @@ -234,6 +234,117 @@ def test_match_cohort_regex_not_in_does_not_negate_inconclusive( ) +@pytest.mark.parametrize("operator", ["regex", "not_regex"]) +@pytest.mark.parametrize( + "pattern,cases", + [ + (r"^\w+$", [("abc", True), ("a\u0301", "defer"), ("!", False), ("é١", True)]), + ( + r"^\s+$", + [ + (" ", True), + ("\x1c", "defer"), + ("\u2003", True), + ("\u2003\x1c", "defer"), + ("\ufeff", False), + ], + ), + (r"^a+$", [("aa", True), ("aa\n", "defer"), ("b", False)]), + (r"^.$", [("😀", True), ("\ud800", "defer"), ("\n", False), ("\r", True)]), + (r"^é+$", [("é", True), ("é\n", "defer"), ("foo", False)]), + ("[", [("value", "invalid"), ("value\n", "invalid"), ("\ud800", "invalid")]), + (r"\p{L}", [("abc", "defer"), ("é", "defer"), ("\n", "defer")]), + ], +) +def test_match_property_regex_same_pattern_different_values(pattern, cases, operator): + for sequence in (cases, list(reversed(cases))): + for value, expected in sequence: + prop = property_filter(pattern, operator) + if expected == "defer": + with pytest.raises(InconclusiveMatchError): + match_property(prop, {"value": value}) + elif expected == "invalid": + assert match_property(prop, {"value": value}) is False + else: + assert match_property(prop, {"value": value}) is ( + expected if operator == "regex" else not expected + ) + + +@pytest.fixture +def regex_pattern_cache(): + from posthog.feature_flags import _compile_regex + + _compile_regex.cache_clear() + yield _compile_regex + _compile_regex.cache_clear() + + +def test_regex_pattern_cache_reuses_metadata_not_match_results(regex_pattern_cache): + prop = property_filter("^cached$", "regex") + assert match_property(prop, {"value": "cached"}) is True + assert match_property(prop, {"value": "other"}) is False + assert ( + match_property(property_filter("^cached$", "not_regex"), {"value": "other"}) + is True + ) + info = regex_pattern_cache.cache_info() + assert (info.misses, info.hits, info.currsize) == (1, 2, 1) + metadata = regex_pattern_cache("^cached$") + assert regex_pattern_cache("^cached$") is metadata + with pytest.raises(AttributeError): + metadata.uses_word_class = True + + +def test_regex_pattern_cache_is_bounded_and_evicts_least_recently_used( + regex_pattern_cache, +): + first = regex_pattern_cache("^cache-first$") + second = regex_pattern_cache("^cache-second$") + limit = regex_pattern_cache.cache_info().maxsize + assert limit == 256 + for index in range(limit - 2): + regex_pattern_cache(f"^cache-{index}$") + assert regex_pattern_cache("^cache-first$") is first + regex_pattern_cache("^cache-overflow$") + info = regex_pattern_cache.cache_info() + assert info.currsize == limit + assert regex_pattern_cache("^cache-first$") is first + assert regex_pattern_cache.cache_info().misses == info.misses + assert regex_pattern_cache("^cache-second$") is not second + assert regex_pattern_cache.cache_info().misses == info.misses + 1 + assert regex_pattern_cache.cache_info().currsize == limit + + +def test_regex_pattern_cache_reuses_invalid_classification(regex_pattern_cache): + assert regex_pattern_cache("[") is None + for operator in ("regex", "not_regex"): + assert match_property(property_filter("[", operator), {"value": "x\n"}) is False + info = regex_pattern_cache.cache_info() + assert (info.misses, info.hits, info.currsize) == (1, 2, 1) + + +def test_regex_pattern_cache_does_not_retain_exceptions(regex_pattern_cache): + errors = [] + for _ in range(2): + with pytest.raises(InconclusiveMatchError) as raised: + regex_pattern_cache(r"\p{L}") + errors.append(raised.value) + assert errors[0] is not errors[1] + info = regex_pattern_cache.cache_info() + assert (info.misses, info.hits, info.currsize) == (2, 0, 0) + + +def test_regex_empty_pattern_bypasses_pattern_cache(regex_pattern_cache): + for operator in ("regex", "not_regex"): + for value in ("", "é", "\n"): + assert match_property(property_filter("", operator), {"value": value}) is ( + operator == "regex" + ) + info = regex_pattern_cache.cache_info() + assert (info.misses, info.hits, info.currsize) == (0, 0, 0) + + @pytest.fixture def client(): instance = Client(FAKE_TEST_API_KEY, send=False)