diff --git a/api/experimentation/dataclasses.py b/api/experimentation/dataclasses.py index 1766ea5aeb44..0c780a1529f8 100644 --- a/api/experimentation/dataclasses.py +++ b/api/experimentation/dataclasses.py @@ -24,6 +24,12 @@ class WarehouseEventStats: unique_events_count: int +@dataclass(frozen=True) +class WarehouseEventNames: + events: list[str] + is_truncated: bool + + @dataclass(frozen=True) class ExposureBucket: variant: str diff --git a/api/experimentation/services.py b/api/experimentation/services.py index e94da042c0f9..ee8c1c2bff75 100644 --- a/api/experimentation/services.py +++ b/api/experimentation/services.py @@ -1,5 +1,7 @@ from __future__ import annotations +import hashlib +import json import time import typing from dataclasses import replace @@ -42,6 +44,7 @@ ResultsAggregates, ResultsSummary, RolloutSpec, + WarehouseEventNames, WarehouseEventStats, ) from experimentation.metrics import ( @@ -102,9 +105,27 @@ CLICKHOUSE_CONNECT_TIMEOUT_SECONDS = 5 CLICKHOUSE_QUERY_TIMEOUT_SECONDS = 30 CLICKHOUSE_VERIFY_TIMEOUT_SECONDS = 5 +CLICKHOUSE_EVENT_NAMES_TIMEOUT_SECONDS = 15 CUSTOMER_EVENT_STATS_CACHE_SECONDS = 60 +EVENT_NAMES_CACHE_SECONDS = 300 +CUSTOMER_EVENT_NAMES_FAILURE_CACHE_SECONDS = 60 +WAREHOUSE_EVENT_NAMES_LIMIT = 500 + +_CUSTOMER_EVENT_UNAVAILABLE = "unavailable" + + +def _customer_cache_key(kind: str, connection: "WarehouseConnection") -> str: + """Key cached warehouse reads by the connection's non-secret details, so a + config or type change can neither serve nor store stale reads. Credentials + stay out of the key material: they don't determine what the warehouse + holds, so rotating them keeps the cache valid.""" + details = json.dumps( + [connection.warehouse_type, connection.config], + sort_keys=True, + ) + digest = hashlib.sha256(details.encode()).hexdigest()[:12] + return f"experimentation:customer_{kind}:{connection.id}:{digest}" -_CUSTOMER_EVENT_STATS_UNAVAILABLE = "unavailable" # A delivery run stops taking on new objects after this long, leaving room for # the slowest possible in-flight insert to still land inside the task timeout. @@ -142,16 +163,72 @@ def _get_clickhouse_client() -> Client: return Client(host, **kwargs) -def get_unique_event_names(environment_key: str) -> list[str]: - """Return the distinct event names recorded for `environment_key`, - ordered alphabetically.""" - rows = _get_clickhouse_client().execute( - "SELECT DISTINCT event FROM events " - "WHERE environment_key = %(environment_key)s " - "ORDER BY event", - {"environment_key": environment_key}, +_CLICKHOUSE_EVENT_NAMES_QUERY = ( + "SELECT event FROM events " + "WHERE environment_key = %(environment_key)s " + "GROUP BY event ORDER BY max(timestamp) DESC LIMIT %(limit)s" +) + + +def _event_names_query_params(environment_key: str) -> dict[str, str | int]: + # Fetch one row past the limit so truncation is detectable. + return { + "environment_key": environment_key, + "limit": WAREHOUSE_EVENT_NAMES_LIMIT + 1, + } + + +def _build_event_names( + rows: "Sequence[Sequence[typing.Any]]", +) -> WarehouseEventNames: + names = [event for (event,) in rows] + return WarehouseEventNames( + events=names[:WAREHOUSE_EVENT_NAMES_LIMIT], + is_truncated=len(names) > WAREHOUSE_EVENT_NAMES_LIMIT, ) - return [row[0] for row in rows] + + +EVENT_NAMES_SUPPORTED_WAREHOUSE_TYPES = ( + WarehouseType.FLAGSMITH, + WarehouseType.CLICKHOUSE, +) + + +def get_warehouse_event_names( + connection: "WarehouseConnection", + environment_key: str, +) -> WarehouseEventNames | None: + if connection.warehouse_type == WarehouseType.CLICKHOUSE: + return _get_customer_clickhouse_event_names(connection, environment_key) + if connection.warehouse_type == WarehouseType.FLAGSMITH: + return _get_flagsmith_clickhouse_event_names(environment_key) + raise ValueError(f"Unsupported warehouse type: {connection.warehouse_type}") + + +def _get_flagsmith_clickhouse_event_names( + environment_key: str, +) -> WarehouseEventNames | None: + if not settings.EXPERIMENTATION_CLICKHOUSE_URL: + return None + cache_key = f"experimentation:event_names:{environment_key}" + cached = cache.get(cache_key) + if isinstance(cached, WarehouseEventNames): + return cached + try: + rows = _get_clickhouse_client().execute( + _CLICKHOUSE_EVENT_NAMES_QUERY, + _event_names_query_params(environment_key), + ) + except Exception: + logger.warning( + "connection.event_names_failed", + environment__key=environment_key, + exc_info=True, + ) + return None + event_names = _build_event_names(rows) + cache.set(cache_key, event_names, EVENT_NAMES_CACHE_SECONDS) + return event_names _EVENT_STATS_QUERY = ( @@ -1079,11 +1156,11 @@ def _get_customer_warehouse_event_stats_cached( ClickHouse instance, or None when it's unreachable. Results — including failures — are cached briefly so read endpoints don't open a connection to the customer's host on every request.""" - cache_key = f"experimentation:customer_event_stats:{connection.id}" + cache_key = _customer_cache_key("event_stats", connection) cached = cache.get(cache_key) if isinstance(cached, WarehouseEventStats): return cached - if cached == _CUSTOMER_EVENT_STATS_UNAVAILABLE: + if cached == _CUSTOMER_EVENT_UNAVAILABLE: return None try: with warehouse_delivery_service.delivery_client( @@ -1098,7 +1175,7 @@ def _get_customer_warehouse_event_stats_cached( except Exception: cache.set( cache_key, - _CUSTOMER_EVENT_STATS_UNAVAILABLE, + _CUSTOMER_EVENT_UNAVAILABLE, CUSTOMER_EVENT_STATS_CACHE_SECONDS, ) logger.warning( @@ -1109,3 +1186,41 @@ def _get_customer_warehouse_event_stats_cached( return None cache.set(cache_key, stats, CUSTOMER_EVENT_STATS_CACHE_SECONDS) return stats + + +def _get_customer_clickhouse_event_names( + connection: "WarehouseConnection", + environment_key: str, +) -> WarehouseEventNames | None: + """Query the customer's ClickHouse instance, caching results — including + failures — to spare their host repeated connections.""" + cache_key = _customer_cache_key("event_names", connection) + cached = cache.get(cache_key) + if isinstance(cached, WarehouseEventNames): + return cached + if cached == _CUSTOMER_EVENT_UNAVAILABLE: + return None + try: + with warehouse_delivery_service.delivery_client( + connection, + send_receive_timeout=CLICKHOUSE_EVENT_NAMES_TIMEOUT_SECONDS, + ) as client: + rows = client.query( + _CLICKHOUSE_EVENT_NAMES_QUERY, + parameters=_event_names_query_params(environment_key), + ).result_rows + except Exception: + cache.set( + cache_key, + _CUSTOMER_EVENT_UNAVAILABLE, + CUSTOMER_EVENT_NAMES_FAILURE_CACHE_SECONDS, + ) + logger.warning( + "connection.event_names_failed", + environment__id=connection.environment_id, + exc_info=True, + ) + return None + event_names = _build_event_names(rows) + cache.set(cache_key, event_names, EVENT_NAMES_CACHE_SECONDS) + return event_names diff --git a/api/experimentation/views.py b/api/experimentation/views.py index 785aa8ae367e..5ab97d319c3c 100644 --- a/api/experimentation/views.py +++ b/api/experimentation/views.py @@ -1,4 +1,5 @@ import logging +from dataclasses import asdict from datetime import timedelta from typing import Any @@ -59,12 +60,14 @@ WarehouseConnectionSerializer, ) from experimentation.services import ( + EVENT_NAMES_SUPPORTED_WAREHOUSE_TYPES, annotate_warehouse_event_stats, apply_experiment_rollout, create_experiment_audit_log, create_metric_audit_log, create_warehouse_audit_log, enable_experiment_rollout, + get_warehouse_event_names, mark_warehouse_pending_connection, refresh_warehouse_connection_status, transition_experiment_status, @@ -106,7 +109,7 @@ def get_throttles(self) -> list[BaseThrottle]: ): self.throttle_scope = "warehouse_connection_write" return [*super().get_throttles(), ScopedRateThrottle()] - if self.action in ("list", "retrieve"): + if self.action in ("list", "retrieve", "events"): self.throttle_scope = "warehouse_connection_read" return [*super().get_throttles(), ScopedRateThrottle()] return super().get_throttles() @@ -222,6 +225,45 @@ def test_warehouse_connection_config( {"status": connection.status, "status_detail": connection.status_detail} ) + @extend_schema( + operation_id="api_v1_environments_warehouse_connections_events_list", + responses={ + 200: inline_serializer( + name="WarehouseEventNamesResult", + fields={ + "events": serializers.ListField(child=serializers.CharField()), + "is_truncated": serializers.BooleanField(), + }, + ), + 400: inline_serializer( + name="WarehouseEventNamesUnsupported", + fields={"detail": serializers.CharField()}, + ), + 503: inline_serializer( + name="WarehouseEventNamesUnavailable", + fields={"detail": serializers.CharField()}, + ), + }, + ) + @action(detail=True, methods=["get"], url_path="events") + def events(self, request: Request, **kwargs: object) -> Response: + """List the distinct event names in the connection's warehouse.""" + connection: WarehouseConnection = self.get_object() + if connection.warehouse_type not in EVENT_NAMES_SUPPORTED_WAREHOUSE_TYPES: + return Response( + {"detail": "Event listing is not supported for this warehouse type."}, + status=status.HTTP_400_BAD_REQUEST, + ) + event_names = get_warehouse_event_names( + connection, self.kwargs["environment_api_key"] + ) + if event_names is None: + return Response( + {"detail": "The warehouse is currently unreachable."}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + return Response(asdict(event_names)) + def create(self, request: Request, *args: object, **kwargs: object) -> Response: environment = self._get_environment() serializer = self.get_serializer(data=request.data) diff --git a/api/tests/unit/experimentation/test_services.py b/api/tests/unit/experimentation/test_services.py index 3601638a7ce0..19fbb481b840 100644 --- a/api/tests/unit/experimentation/test_services.py +++ b/api/tests/unit/experimentation/test_services.py @@ -22,6 +22,7 @@ MetricSpec, ResultsAggregates, RolloutSpec, + WarehouseEventNames, WarehouseEventStats, ) from experimentation.models import ( @@ -108,29 +109,211 @@ def test_get_clickhouse_client__dsn_timeouts__are_preserved( services._get_clickhouse_client.cache_clear() -def test_get_unique_event_names__events_present__returns_ordered_names( +@pytest.mark.parametrize( + "rows, expected", + [ + ( + [("conversion",), ("page_view",)], + WarehouseEventNames(events=["conversion", "page_view"], is_truncated=False), + ), + ([], WarehouseEventNames(events=[], is_truncated=False)), + ( + [(f"event_{i:03d}",) for i in range(501)], + WarehouseEventNames( + events=[f"event_{i:03d}" for i in range(500)], is_truncated=True + ), + ), + ], + ids=["few", "none", "truncated"], +) +def test_get_warehouse_event_names__flagsmith_connection__returns_capped_names( + warehouse_connection: WarehouseConnection, + settings: SettingsWrapper, + reset_cache: None, + rows: list[tuple[str]], + expected: WarehouseEventNames, mocker: MockerFixture, ) -> None: # Given + settings.EXPERIMENTATION_CLICKHOUSE_URL = "clickhouse://ch.example.com/db" mock_client = mocker.Mock() - mock_client.execute.return_value = [("conversion",), ("page_view",)] + mock_client.execute.return_value = rows mocker.patch( "experimentation.services._get_clickhouse_client", return_value=mock_client, ) # When - result = services.get_unique_event_names("env-key-123") + result = services.get_warehouse_event_names(warehouse_connection, "env-key-123") # Then - assert result == ["conversion", "page_view"] + assert result == expected mock_client.execute.assert_called_once_with( - "SELECT DISTINCT event FROM events " + "SELECT event FROM events " "WHERE environment_key = %(environment_key)s " - "ORDER BY event", - {"environment_key": "env-key-123"}, + "GROUP BY event ORDER BY max(timestamp) DESC LIMIT %(limit)s", + {"environment_key": "env-key-123", "limit": 501}, + ) + + # When — the result is cached, so a second request doesn't hit the warehouse + second_result = services.get_warehouse_event_names( + warehouse_connection, "env-key-123" + ) + + # Then + mock_client.execute.assert_called_once() + assert second_result == expected + + +@pytest.mark.parametrize( + "clickhouse_url, execute_side_effect", + [ + ("", None), + ("clickhouse://ch.example.com/db", Exception("connection refused")), + ], + ids=["unconfigured", "unreachable"], +) +def test_get_warehouse_event_names__flagsmith_warehouse_unavailable__returns_none( + warehouse_connection: WarehouseConnection, + settings: SettingsWrapper, + reset_cache: None, + clickhouse_url: str, + execute_side_effect: Exception | None, + log: StructuredLogCapture, + mocker: MockerFixture, +) -> None: + # Given + settings.EXPERIMENTATION_CLICKHOUSE_URL = clickhouse_url + mock_client = mocker.Mock() + mock_client.execute.side_effect = execute_side_effect + mocker.patch( + "experimentation.services._get_clickhouse_client", + return_value=mock_client, ) + # When + result = services.get_warehouse_event_names(warehouse_connection, "env-key-123") + + # Then + assert result is None + assert any( + event["event"] == "connection.event_names_failed" for event in log.events + ) == (execute_side_effect is not None) + + +@pytest.mark.parametrize( + "query_result, expected", + [ + ( + [("conversion",), ("page_view",)], + WarehouseEventNames(events=["conversion", "page_view"], is_truncated=False), + ), + (Exception("connection refused"), None), + ], + ids=["reachable", "unreachable"], +) +def test_get_warehouse_event_names__clickhouse_connection__queries_customer_instance( + clickhouse_connection: WarehouseConnection, + reset_cache: None, + query_result: Exception | list[tuple[str]], + expected: WarehouseEventNames | None, + log: StructuredLogCapture, + mocker: MockerFixture, +) -> None: + # Given + get_client = mocker.patch( + "experimentation.warehouse_delivery_service.clickhouse_connect.get_client", + ) + if isinstance(query_result, Exception): + get_client.return_value.query.side_effect = query_result + else: + get_client.return_value.query.return_value = mocker.Mock( + result_rows=query_result + ) + + # When + result = services.get_warehouse_event_names(clickhouse_connection, "test-env-key") + + # Then + assert result == expected + get_client.return_value.query.assert_called_once_with( + "SELECT event FROM events " + "WHERE environment_key = %(environment_key)s " + "GROUP BY event ORDER BY max(timestamp) DESC LIMIT %(limit)s", + parameters={"environment_key": "test-env-key", "limit": 501}, + ) + get_client.return_value.close.assert_called_once_with() + assert any( + event["event"] == "connection.event_names_failed" for event in log.events + ) == (expected is None) + + # When — the outcome is cached, so a second request doesn't reconnect + fresh_connection = WarehouseConnection.objects.get(id=clickhouse_connection.id) + second_result = services.get_warehouse_event_names(fresh_connection, "test-env-key") + + # Then + get_client.assert_called_once() + assert second_result == expected + + +@pytest.mark.parametrize( + "changed_field, new_value, expected_events, expected_query_count", + [ + ("config", {"host": "new.acme-corp.example"}, ["new_event"], 2), + ("credentials", {"password": "rotated"}, ["old_event"], 1), + ], + ids=["config-bypasses-cache", "credentials-keep-cache"], +) +def test_get_warehouse_event_names__connection_details_changed__cache_keyed_by_config( + clickhouse_connection: WarehouseConnection, + reset_cache: None, + changed_field: str, + new_value: dict[str, str], + expected_events: list[str], + expected_query_count: int, + mocker: MockerFixture, +) -> None: + # Given — a cached result for the connection's current details + get_client = mocker.patch( + "experimentation.warehouse_delivery_service.clickhouse_connect.get_client", + ) + get_client.return_value.query.return_value = mocker.Mock( + result_rows=[("old_event",)] + ) + services.get_warehouse_event_names(clickhouse_connection, "test-env-key") + + # When — the connection details change + setattr( + clickhouse_connection, + changed_field, + {**getattr(clickhouse_connection, changed_field), **new_value}, + ) + clickhouse_connection.save() + get_client.return_value.query.return_value = mocker.Mock( + result_rows=[("new_event",)] + ) + result = services.get_warehouse_event_names(clickhouse_connection, "test-env-key") + + # Then — a config change re-queries; a credential rotation keeps the cache + assert result == WarehouseEventNames(events=expected_events, is_truncated=False) + assert get_client.return_value.query.call_count == expected_query_count + + +def test_get_warehouse_event_names__unsupported_type__raises( + environment: Environment, +) -> None: + # Given + connection = WarehouseConnection( + environment=environment, + warehouse_type=WarehouseType.SNOWFLAKE, + name="Snowflake", + config={"account_identifier": "acme"}, + ) + + # When / Then + with pytest.raises(ValueError, match="Unsupported warehouse type"): + services.get_warehouse_event_names(connection, "test-env-key") + def test_get_exposure_buckets__day_granularity__queries_and_maps_rows( mocker: MockerFixture, @@ -395,24 +578,6 @@ def test_build_exposures_summary__no_buckets__empty_summary() -> None: ) -def test_get_unique_event_names__no_events__returns_empty_list( - mocker: MockerFixture, -) -> None: - # Given - mock_client = mocker.Mock() - mock_client.execute.return_value = [] - mocker.patch( - "experimentation.services._get_clickhouse_client", - return_value=mock_client, - ) - - # When - result = services.get_unique_event_names("env-key-123") - - # Then - assert result == [] - - @pytest.mark.parametrize( "rows, expected_total, expected_unique", [ diff --git a/api/tests/unit/experimentation/test_views.py b/api/tests/unit/experimentation/test_views.py index fe01bad5b813..543c3b26969e 100644 --- a/api/tests/unit/experimentation/test_views.py +++ b/api/tests/unit/experimentation/test_views.py @@ -13,7 +13,7 @@ from audit.related_object_type import RelatedObjectType from environments.models import Environment from experimentation import services -from experimentation.dataclasses import WarehouseEventStats +from experimentation.dataclasses import WarehouseEventNames, WarehouseEventStats from experimentation.models import ( WarehouseConnection, WarehouseConnectionStatus, @@ -1846,3 +1846,79 @@ def test_patch__clickhouse_null_credentials__returns_400( assert "password" in response.json()["credentials"] clickhouse_connection.refresh_from_db() assert clickhouse_connection.credentials == {"password": "hunter2"} + + +@pytest.mark.parametrize( + "service_result, expected_status, expected_body", + [ + ( + WarehouseEventNames(events=["conversion"], is_truncated=False), + status.HTTP_200_OK, + {"events": ["conversion"], "is_truncated": False}, + ), + ( + None, + status.HTTP_503_SERVICE_UNAVAILABLE, + {"detail": "The warehouse is currently unreachable."}, + ), + ], + ids=["reachable", "unreachable"], +) +def test_get_events__warehouse_availability__maps_to_response( + admin_client: APIClient, + environment: Environment, + warehouse_connection: WarehouseConnection, + enable_features: EnableFeaturesFixture, + service_result: WarehouseEventNames | None, + expected_status: int, + expected_body: dict[str, object], + mocker: MockerFixture, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + mocker.patch( + "experimentation.views.get_warehouse_event_names", + return_value=service_result, + ) + url = reverse( + "api-v1:environments:experimentation:warehouse-connections-events", + args=[environment.api_key, warehouse_connection.id], + ) + + # When + response = admin_client.get(url) + + # Then + assert response.status_code == expected_status + assert response.json() == expected_body + + +def test_get_events__unsupported_type__returns_400( + admin_client: APIClient, + environment: Environment, + enable_features: EnableFeaturesFixture, + mocker: MockerFixture, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + get_event_names = mocker.patch("experimentation.views.get_warehouse_event_names") + connection = WarehouseConnection.objects.create( + environment=environment, + warehouse_type=WarehouseType.SNOWFLAKE, + name="Snowflake", + config={"account_identifier": "acme"}, + ) + url = reverse( + "api-v1:environments:experimentation:warehouse-connections-events", + args=[environment.api_key, connection.id], + ) + + # When + response = admin_client.get(url) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == { + "detail": "Event listing is not supported for this warehouse type." + } + get_event_names.assert_not_called() diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 0c8a6b9066b4..591712fced24 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -660,16 +660,27 @@ Attributes: ### `warehouse.connection.connected` Logged at `info` from: - - `api/experimentation/services.py:1041` + - `api/experimentation/services.py:1118` Attributes: - `environment.id` - `organisation.id` +### `warehouse.connection.event_names_failed` + +Logged at `warning` from: + - `api/experimentation/services.py:223` + - `api/experimentation/services.py:1218` + +Attributes: + - `environment.id` + - `environment.key` + - `exc_info` + ### `warehouse.connection.event_stats_failed` Logged at `warning` from: - - `api/experimentation/services.py:1104` + - `api/experimentation/services.py:1181` Attributes: - `environment.id` @@ -678,7 +689,7 @@ Attributes: ### `warehouse.connection.test_event_sent` Logged at `info` from: - - `api/experimentation/services.py:815` + - `api/experimentation/services.py:892` Attributes: - `environment.id` @@ -687,7 +698,7 @@ Attributes: ### `warehouse.connection.verification_failed` Logged at `warning` from: - - `api/experimentation/services.py:1016` + - `api/experimentation/services.py:1093` Attributes: - `environment.id` @@ -697,7 +708,7 @@ Attributes: ### `warehouse.connection.verification_succeeded` Logged at `info` from: - - `api/experimentation/services.py:1026` + - `api/experimentation/services.py:1103` Attributes: - `environment.id` @@ -706,7 +717,7 @@ Attributes: ### `warehouse.delivery.all_objects_rejected` Logged at `error` from: - - `api/experimentation/services.py:971` + - `api/experimentation/services.py:1048` Attributes: - `connection.id` @@ -717,7 +728,7 @@ Attributes: ### `warehouse.delivery.budget_exhausted` Logged at `info` from: - - `api/experimentation/services.py:860` + - `api/experimentation/services.py:937` Attributes: - `connection.id` @@ -728,7 +739,7 @@ Attributes: ### `warehouse.delivery.completed` Logged at `info` from: - - `api/experimentation/services.py:981` + - `api/experimentation/services.py:1058` Attributes: - `connection.id` @@ -741,7 +752,7 @@ Attributes: ### `warehouse.delivery.failed` Logged at `error` from: - - `api/experimentation/services.py:954` + - `api/experimentation/services.py:1031` Attributes: - `connection.id` @@ -752,7 +763,7 @@ Attributes: ### `warehouse.delivery.object_rejected` Logged at `error` from: - - `api/experimentation/services.py:889` + - `api/experimentation/services.py:966` Attributes: - `connection.id` @@ -764,7 +775,7 @@ Attributes: ### `warehouse.srm.overallocated` Logged at `error` from: - - `api/experimentation/services.py:437` + - `api/experimentation/services.py:514` Attributes: - `environment.id` @@ -774,7 +785,7 @@ Attributes: ### `warehouse.srm.unkeyed_variant` Logged at `error` from: - - `api/experimentation/services.py:423` + - `api/experimentation/services.py:500` Attributes: - `environment.id` diff --git a/openapi.yaml b/openapi.yaml index 4086dbf7c689..ccfdf0f08579 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -7069,6 +7069,46 @@ paths: - Master API Key: [] tags: - Environments + '/api/v1/environments/{environment_api_key}/warehouse-connections/{connection_id}/events/': + get: + operationId: api_v1_environments_warehouse_connections_events_list + description: List the distinct event names in the connection's warehouse. + parameters: + - name: connection_id + in: path + description: A unique integer value identifying this warehouse connection. + required: true + schema: + type: integer + - name: environment_api_key + in: path + required: true + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/WarehouseEventNamesResult' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/WarehouseEventNamesUnsupported' + '503': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/WarehouseEventNamesUnavailable' + security: + - tokenAuth: [] + - Master API Key: [] + tags: + - Environments '/api/v1/environments/{environment_api_key}/warehouse-connections/{connection_id}/test-warehouse-connection/': post: operationId: api_v1_environments_warehouse_connections_test_warehouse_connection_create @@ -27950,6 +27990,32 @@ components: required: - status - status_detail + WarehouseEventNamesResult: + type: object + properties: + events: + type: array + items: + type: string + is_truncated: + type: boolean + required: + - events + - is_truncated + WarehouseEventNamesUnavailable: + type: object + properties: + detail: + type: string + required: + - detail + WarehouseEventNamesUnsupported: + type: object + properties: + detail: + type: string + required: + - detail WarehouseTypeEnum: description: |- * `flagsmith` - Flagsmith