99from clickhouse_driver import errors as clickhouse_errors
1010from clickhouse_driver .util .helpers import parse_url
1111from django .conf import settings
12+ from django .core .cache import cache
1213from django .db import transaction
1314from django .db .models import Q
1415from django .utils import timezone
9596CLICKHOUSE_CONNECT_TIMEOUT_SECONDS = 5
9697CLICKHOUSE_QUERY_TIMEOUT_SECONDS = 30
9798CLICKHOUSE_VERIFY_TIMEOUT_SECONDS = 5
99+ CUSTOMER_EVENT_STATS_CACHE_SECONDS = 60
100+
101+ _CUSTOMER_EVENT_STATS_UNAVAILABLE = "unavailable"
98102
99103
100104def 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+
157165EXPOSURE_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+
802835def _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
0 commit comments