Skip to content

Commit 551ac44

Browse files
feat: verify ClickHouse events table and query customer event stats (#8116)
Co-authored-by: flagsmith-engineering[bot] <flagsmith-engineering[bot]@users.noreply.github.com>
1 parent 4d32782 commit 551ac44

8 files changed

Lines changed: 200 additions & 46 deletions

File tree

api/app/settings/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@
367367
"user": USER_THROTTLE_RATE,
368368
"influx_query": "5/min",
369369
"warehouse_connection_write": "10/min",
370+
"warehouse_connection_read": "60/min",
370371
},
371372
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
372373
"DEFAULT_RENDERER_CLASSES": [

api/app/settings/test.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"master_api_key": "100000/day",
2929
"influx_query": "50/min",
3030
"warehouse_connection_write": "1000/min",
31+
"warehouse_connection_read": "1000/min",
3132
}
3233

3334
AWS_SSE_LOGS_BUCKET_NAME = "test_bucket"

api/experimentation/services.py

Lines changed: 95 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from clickhouse_driver import errors as clickhouse_errors
1010
from clickhouse_driver.util.helpers import parse_url
1111
from django.conf import settings
12+
from django.core.cache import cache
1213
from django.db import transaction
1314
from django.db.models import Q
1415
from django.utils import timezone
@@ -95,6 +96,9 @@
9596
CLICKHOUSE_CONNECT_TIMEOUT_SECONDS = 5
9697
CLICKHOUSE_QUERY_TIMEOUT_SECONDS = 30
9798
CLICKHOUSE_VERIFY_TIMEOUT_SECONDS = 5
99+
CUSTOMER_EVENT_STATS_CACHE_SECONDS = 60
100+
101+
_CUSTOMER_EVENT_STATS_UNAVAILABLE = "unavailable"
98102

99103

100104
def is_warehouse_feature_enabled(organisation: Organisation) -> bool:
@@ -140,9 +144,8 @@ def get_unique_event_names(environment_key: str) -> list[str]:
140144
return [row[0] for row in rows]
141145

142146

143-
def get_warehouse_event_stats(environment_key: str) -> WarehouseEventStats:
144-
"""Return event counts recorded for `environment_key` in the warehouse."""
145-
rows = _get_clickhouse_client().execute(
147+
def _query_event_stats(client: Client, environment_key: str) -> WarehouseEventStats:
148+
rows = client.execute(
146149
"SELECT count() AS total, uniqExact(event) AS unique "
147150
"FROM events WHERE environment_key = %(environment_key)s",
148151
{"environment_key": environment_key},
@@ -154,6 +157,11 @@ def get_warehouse_event_stats(environment_key: str) -> WarehouseEventStats:
154157
)
155158

156159

160+
def get_warehouse_event_stats(environment_key: str) -> WarehouseEventStats:
161+
"""Return event counts recorded for `environment_key` in the warehouse."""
162+
return _query_event_stats(_get_clickhouse_client(), environment_key)
163+
164+
157165
EXPOSURE_BUCKETS_QUERY = (
158166
_EXPOSURES_CTE
159167
+ """
@@ -799,6 +807,31 @@ class InternalAddressError(Exception):
799807
pass
800808

801809

810+
class MissingEventsTableError(Exception):
811+
pass
812+
813+
814+
def _connect_customer_clickhouse(connection: WarehouseConnection) -> Client:
815+
"""Build a client for the customer's ClickHouse instance from stored
816+
connection details, re-checking the host against internal address ranges
817+
first: DNS may resolve differently than it did at validation time, and
818+
rows may predate host validation."""
819+
config = typing.cast(ClickHouseConfig, connection.config or {})
820+
credentials = typing.cast(ClickHouseCredentials, connection.credentials or {})
821+
if is_internal_address(config["host"], include_shared=True):
822+
raise InternalAddressError(config["host"])
823+
return Client(
824+
config["host"],
825+
port=config["port"],
826+
user=config["username"],
827+
password=credentials["password"],
828+
database=config["database"],
829+
secure=config["secure"],
830+
connect_timeout=CLICKHOUSE_CONNECT_TIMEOUT_SECONDS,
831+
send_receive_timeout=CLICKHOUSE_VERIFY_TIMEOUT_SECONDS,
832+
)
833+
834+
802835
def _describe_verification_error(error: Exception) -> str:
803836
if isinstance(error, clickhouse_errors.ServerException):
804837
# 516 = AUTHENTICATION_FAILED (not in clickhouse_driver.errors.ErrorCodes)
@@ -813,6 +846,11 @@ def _describe_verification_error(error: Exception) -> str:
813846
return "Could not connect to the host."
814847
if isinstance(error, InternalAddressError):
815848
return "Host must not target internal or private network addresses."
849+
if isinstance(error, MissingEventsTableError):
850+
return (
851+
"Events table not found in the configured database. "
852+
"Run the setup SQL to create it."
853+
)
816854
if isinstance(error, KeyError):
817855
return "Stored connection details are incomplete."
818856
return "Verification failed."
@@ -822,30 +860,19 @@ def verify_clickhouse_connection(
822860
connection: WarehouseConnection,
823861
persist: bool = True,
824862
) -> None:
825-
"""Run SELECT 1 against the customer's ClickHouse and set the status to
826-
connected or errored; never raises. With persist=False, the status is only
827-
set on the in-memory instance, allowing unsaved connections to be tested."""
863+
"""Run SELECT 1 against the customer's ClickHouse, check the events table
864+
exists, and set the status to connected or errored; never raises. With
865+
persist=False, the status is only set on the in-memory instance, allowing
866+
unsaved connections to be tested."""
828867
log = logger.bind(environment__id=connection.environment_id)
829868
try:
830869
log = log.bind(organisation__id=connection.environment.project.organisation_id)
831-
config = typing.cast(ClickHouseConfig, connection.config or {})
832-
credentials = typing.cast(ClickHouseCredentials, connection.credentials or {})
833-
# Re-check right before connecting: DNS may resolve differently than it
834-
# did at validation time, and rows may predate host validation.
835-
if is_internal_address(config["host"], include_shared=True):
836-
raise InternalAddressError(config["host"])
837-
client = Client(
838-
config["host"],
839-
port=config["port"],
840-
user=config["username"],
841-
password=credentials["password"],
842-
database=config["database"],
843-
secure=config["secure"],
844-
connect_timeout=CLICKHOUSE_CONNECT_TIMEOUT_SECONDS,
845-
send_receive_timeout=CLICKHOUSE_VERIFY_TIMEOUT_SECONDS,
846-
)
870+
client = _connect_customer_clickhouse(connection)
847871
try:
848872
client.execute("SELECT 1")
873+
rows = client.execute("EXISTS TABLE events")
874+
if not rows[0][0]:
875+
raise MissingEventsTableError()
849876
finally:
850877
client.disconnect()
851878
except Exception as error:
@@ -893,9 +920,16 @@ def annotate_warehouse_event_stats(
893920
connection: WarehouseConnection,
894921
environment_key: str,
895922
) -> None:
896-
"""Attach live warehouse event stats to a flagsmith connection. No-op for
897-
non-flagsmith connections or when no warehouse is configured; leaves stats
898-
unset when the warehouse is unreachable. Read-only: never changes status."""
923+
"""Attach live warehouse event stats to a connection — from the managed
924+
warehouse for flagsmith connections, from the customer's instance for
925+
clickhouse ones. No-op for other types or when no warehouse is configured;
926+
leaves stats unset when the warehouse is unreachable. Read-only: never
927+
changes status."""
928+
if connection.warehouse_type == WarehouseType.CLICKHOUSE:
929+
stats = _get_customer_warehouse_event_stats_cached(connection, environment_key)
930+
if stats is not None:
931+
connection.event_stats = stats
932+
return
899933
if (
900934
connection.warehouse_type != WarehouseType.FLAGSMITH
901935
or not settings.EXPERIMENTATION_CLICKHOUSE_URL
@@ -905,3 +939,39 @@ def annotate_warehouse_event_stats(
905939
connection.event_stats = get_warehouse_event_stats(environment_key)
906940
except Exception:
907941
return
942+
943+
944+
def _get_customer_warehouse_event_stats_cached(
945+
connection: WarehouseConnection,
946+
environment_key: str,
947+
) -> WarehouseEventStats | None:
948+
"""Return event counts recorded for `environment_key` in the customer's
949+
ClickHouse instance, or None when it's unreachable. Results — including
950+
failures — are cached briefly so read endpoints don't open a connection to
951+
the customer's host on every request."""
952+
cache_key = f"experimentation:customer_event_stats:{connection.id}"
953+
cached = cache.get(cache_key)
954+
if isinstance(cached, WarehouseEventStats):
955+
return cached
956+
if cached == _CUSTOMER_EVENT_STATS_UNAVAILABLE:
957+
return None
958+
try:
959+
client = _connect_customer_clickhouse(connection)
960+
try:
961+
stats = _query_event_stats(client, environment_key)
962+
finally:
963+
client.disconnect()
964+
except Exception:
965+
cache.set(
966+
cache_key,
967+
_CUSTOMER_EVENT_STATS_UNAVAILABLE,
968+
CUSTOMER_EVENT_STATS_CACHE_SECONDS,
969+
)
970+
logger.warning(
971+
"connection.event_stats_failed",
972+
environment__id=connection.environment_id,
973+
exc_info=True,
974+
)
975+
return None
976+
cache.set(cache_key, stats, CUSTOMER_EVENT_STATS_CACHE_SECONDS)
977+
return stats

api/experimentation/types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ class ClickHouseConfig(TypedDict):
5757
CLICKHOUSE_DEFAULTS: ClickHouseConfig = {
5858
"host": "",
5959
"port": 9440,
60-
"database": "flagsmith",
60+
"database": "flagsmith_exp",
6161
"username": "default",
6262
"secure": True,
6363
}

api/experimentation/views.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ def get_throttles(self) -> list[BaseThrottle]:
106106
):
107107
self.throttle_scope = "warehouse_connection_write"
108108
return [*super().get_throttles(), ScopedRateThrottle()]
109+
if self.action in ("list", "retrieve"):
110+
self.throttle_scope = "warehouse_connection_read"
111+
return [*super().get_throttles(), ScopedRateThrottle()]
109112
return super().get_throttles()
110113

111114
def perform_create(self, serializer: BaseSerializer[WarehouseConnection]) -> None:

api/tests/unit/experimentation/test_services.py

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@
4040
from experimentation.results_query import _MetricSlot
4141
from experimentation.services import (
4242
InternalAddressError,
43+
MissingEventsTableError,
4344
_describe_verification_error,
45+
annotate_warehouse_event_stats,
4446
verify_clickhouse_connection,
4547
)
4648
from experimentation.stats import VariantStats
@@ -2031,6 +2033,7 @@ def test_verify_clickhouse_connection__reachable__sets_connected(
20312033
) -> None:
20322034
# Given
20332035
mock_client = mocker.patch("experimentation.services.Client")
2036+
mock_client.return_value.execute.return_value = [(1,)]
20342037
success_count_before = _verification_count("success")
20352038
clickhouse_connection.status_detail = "stale detail"
20362039
clickhouse_connection.save()
@@ -2052,7 +2055,10 @@ def test_verify_clickhouse_connection__reachable__sets_connected(
20522055
connect_timeout=5,
20532056
send_receive_timeout=5,
20542057
)
2055-
mock_client.return_value.execute.assert_called_once_with("SELECT 1")
2058+
assert mock_client.return_value.execute.call_args_list == [
2059+
mocker.call("SELECT 1"),
2060+
mocker.call("EXISTS TABLE events"),
2061+
]
20562062
mock_client.return_value.disconnect.assert_called_once_with()
20572063
assert _verification_count("success") == success_count_before + 1
20582064
assert {
@@ -2071,14 +2077,20 @@ def test_verify_clickhouse_connection__reachable__sets_connected(
20712077
Exception("connection refused"),
20722078
"Verification failed.",
20732079
),
2080+
(
2081+
{"password": "hunter2"},
2082+
[[(1,)], [(0,)]],
2083+
"Events table not found in the configured database. "
2084+
"Run the setup SQL to create it.",
2085+
),
20742086
(None, None, "Stored connection details are incomplete."),
20752087
],
2076-
ids=["driver_error", "missing_credentials"],
2088+
ids=["driver_error", "missing_events_table", "missing_credentials"],
20772089
)
20782090
def test_verify_clickhouse_connection__failure__sets_errored_with_detail(
20792091
clickhouse_connection: WarehouseConnection,
20802092
credentials: dict[str, str] | None,
2081-
execute_side_effect: Exception | None,
2093+
execute_side_effect: Exception | list[list[tuple[int]]] | None,
20822094
expected_detail: str,
20832095
log: StructuredLogCapture,
20842096
mocker: MockerFixture,
@@ -2161,6 +2173,11 @@ def test_verify_clickhouse_connection__internal_host__sets_errored_without_conne
21612173
InternalAddressError("10.0.0.5"),
21622174
"Host must not target internal or private network addresses.",
21632175
),
2176+
(
2177+
MissingEventsTableError(),
2178+
"Events table not found in the configured database. "
2179+
"Run the setup SQL to create it.",
2180+
),
21642181
(
21652182
KeyError("host"),
21662183
"Stored connection details are incomplete.",
@@ -2178,6 +2195,7 @@ def test_verify_clickhouse_connection__internal_host__sets_errored_without_conne
21782195
"builtin_timeout_error",
21792196
"network_error",
21802197
"internal_address_error",
2198+
"missing_events_table_error",
21812199
"key_error",
21822200
"generic_exception",
21832201
],
@@ -2191,3 +2209,50 @@ def test_describe_verification_error__known_error_types__returns_expected_detail
21912209

21922210
# Then
21932211
assert detail == expected_detail
2212+
2213+
2214+
@pytest.mark.parametrize(
2215+
"execute_side_effect, expected_stats",
2216+
[
2217+
(
2218+
[[(42, 7)]],
2219+
WarehouseEventStats(total_events_received=42, unique_events_count=7),
2220+
),
2221+
(Exception("connection refused"), None),
2222+
],
2223+
ids=["reachable", "unreachable"],
2224+
)
2225+
def test_annotate_warehouse_event_stats__clickhouse_connection__queries_customer_instance(
2226+
clickhouse_connection: WarehouseConnection,
2227+
reset_cache: None,
2228+
execute_side_effect: Exception | list[list[tuple[int, int]]],
2229+
expected_stats: WarehouseEventStats | None,
2230+
log: StructuredLogCapture,
2231+
mocker: MockerFixture,
2232+
) -> None:
2233+
# Given
2234+
mock_client = mocker.patch("experimentation.services.Client")
2235+
mock_client.return_value.execute.side_effect = execute_side_effect
2236+
2237+
# When
2238+
annotate_warehouse_event_stats(clickhouse_connection, "test-env-key")
2239+
2240+
# Then
2241+
assert getattr(clickhouse_connection, "event_stats", None) == expected_stats
2242+
mock_client.return_value.execute.assert_called_once_with(
2243+
"SELECT count() AS total, uniqExact(event) AS unique "
2244+
"FROM events WHERE environment_key = %(environment_key)s",
2245+
{"environment_key": "test-env-key"},
2246+
)
2247+
mock_client.return_value.disconnect.assert_called_once_with()
2248+
assert any(
2249+
event["event"] == "connection.event_stats_failed" for event in log.events
2250+
) == (expected_stats is None)
2251+
2252+
# When — the outcome is cached, so a second request doesn't reconnect
2253+
fresh_connection = WarehouseConnection.objects.get(id=clickhouse_connection.id)
2254+
annotate_warehouse_event_stats(fresh_connection, "test-env-key")
2255+
2256+
# Then
2257+
mock_client.assert_called_once()
2258+
assert getattr(fresh_connection, "event_stats", None) == expected_stats

0 commit comments

Comments
 (0)