From b7e56379e3084a33fb0f1bdf6ded32b0287d6392 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 2 Jun 2026 16:23:52 +0200 Subject: [PATCH 1/5] feat(experimentation): add warehouse test connection mutation and stat types --- frontend/common/services/useWarehouseConnection.ts | 11 +++++++++++ frontend/common/types/requests.ts | 1 + frontend/common/types/responses.ts | 2 ++ 3 files changed, 14 insertions(+) diff --git a/frontend/common/services/useWarehouseConnection.ts b/frontend/common/services/useWarehouseConnection.ts index 2ff3fe3f7527..017fe642a5a3 100644 --- a/frontend/common/services/useWarehouseConnection.ts +++ b/frontend/common/services/useWarehouseConnection.ts @@ -36,6 +36,16 @@ export const warehouseConnectionService = service url: `environments/${environmentId}/warehouse-connections/`, }), }), + testWarehouseConnection: builder.mutation< + Res['warehouseConnections'][number], + Req['testWarehouseConnection'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'WarehouseConnection' }], + query: ({ environmentId, id }) => ({ + method: 'POST', + url: `environments/${environmentId}/warehouse-connections/${id}/test-warehouse-connection/`, + }), + }), updateWarehouseConnection: builder.mutation< Res['warehouseConnections'][number], Req['updateWarehouseConnection'] @@ -54,5 +64,6 @@ export const { useCreateWarehouseConnectionMutation, useDeleteWarehouseConnectionMutation, useGetWarehouseConnectionsQuery, + useTestWarehouseConnectionMutation, useUpdateWarehouseConnectionMutation, } = warehouseConnectionService diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index c4c08e6a1944..82bce5ac402f 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -981,6 +981,7 @@ export type Req = { config?: Record } deleteWarehouseConnection: { environmentId: string; id: number } + testWarehouseConnection: { environmentId: string; id: number } updateWarehouseConnection: { environmentId: string id: number diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 713cf47fac47..dae9642f64ec 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -1146,6 +1146,8 @@ export type WarehouseConnection = { name: string config: SnowflakeConfig | Record created_at: string + total_events_received: number + unique_events_count: number } export type Res = { From fd6b72d9beaba1b6f3895f9b9b3cbc455a1e8cd7 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 2 Jun 2026 16:27:53 +0200 Subject: [PATCH 2/5] feat(experimentation): add ingestor url to env --- frontend/env/project_dev.js | 7 ++++++- frontend/env/project_local.js | 7 ++++++- frontend/env/project_prod.js | 8 +++++++- frontend/env/project_staging.js | 2 ++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/frontend/env/project_dev.js b/frontend/env/project_dev.js index d69548f70853..ff251461e0de 100644 --- a/frontend/env/project_dev.js +++ b/frontend/env/project_dev.js @@ -14,10 +14,15 @@ const Project = { flagsmithClientAPI: 'https://edge.api.flagsmith.com/api/v1/', flagsmithClientEdgeAPI: 'https://edge.bullet-train-staging.win/api/v1/', + + flagsmithClientEventsAPI: 'https://events.bullet-train-staging.win/v1/events', // This is used for Sentry tracking maintenance: false, plans: { - scaleUp: { annual: 'Scale-Up-v4-USD-Yearly', monthly: 'Scale-Up-v4-USD-Monthly' }, + scaleUp: { + annual: 'Scale-Up-v4-USD-Yearly', + monthly: 'Scale-Up-v4-USD-Monthly', + }, startup: { annual: 'startup-annual-v2', monthly: 'startup-v2' }, }, useSecureCookies: true, diff --git a/frontend/env/project_local.js b/frontend/env/project_local.js index cf36706e946d..1d604731b62e 100644 --- a/frontend/env/project_local.js +++ b/frontend/env/project_local.js @@ -14,10 +14,15 @@ const Project = { flagsmithClientAPI: 'https://edge.api.flagsmith.com/api/v1/', flagsmithClientEdgeAPI: 'https://edge.api.flagsmith.com/api/v1/', + + flagsmithClientEventsAPI: 'https://events.bullet-train-staging.win/v1/events', // This is used for Sentry tracking maintenance: false, plans: { - scaleUp: { annual: 'Scale-Up-v4-USD-Yearly', monthly: 'Scale-Up-v4-USD-Monthly' }, + scaleUp: { + annual: 'Scale-Up-v4-USD-Yearly', + monthly: 'Scale-Up-v4-USD-Monthly', + }, startup: { annual: 'startup-annual-v2', monthly: 'startup-v2' }, }, useSecureCookies: false, diff --git a/frontend/env/project_prod.js b/frontend/env/project_prod.js index c1e3306f8103..6c11ce654d80 100644 --- a/frontend/env/project_prod.js +++ b/frontend/env/project_prod.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line @dword-design/import-alias/prefer-alias import { E2E_CHANGE_MAIL, E2E_SIGN_UP_USER, E2E_USER } from '../e2e/config' const _globalThis = typeof window === 'undefined' ? global : window @@ -21,13 +22,18 @@ const Project = { flagsmithClientEdgeAPI: 'https://edge.api.flagsmith.com/api/v1/', + flagsmithClientEventsAPI: 'https://events.api.flagsmith.com/v1/events', + hubspot: '//js-eu1.hs-scripts.com/143451822.js', linkedinConversionId: 16798338, // This is used for Sentry tracking maintenance: false, plans: { - scaleUp: { annual: 'Scale-Up-v4-USD-Yearly', monthly: 'Scale-Up-v4-USD-Monthly' }, + scaleUp: { + annual: 'Scale-Up-v4-USD-Yearly', + monthly: 'Scale-Up-v4-USD-Monthly', + }, startup: { annual: 'start-up-12-months-v2', monthly: 'startup-v2' }, }, useSecureCookies: true, diff --git a/frontend/env/project_staging.js b/frontend/env/project_staging.js index 2b63a9173dcf..ff251461e0de 100644 --- a/frontend/env/project_staging.js +++ b/frontend/env/project_staging.js @@ -14,6 +14,8 @@ const Project = { flagsmithClientAPI: 'https://edge.api.flagsmith.com/api/v1/', flagsmithClientEdgeAPI: 'https://edge.bullet-train-staging.win/api/v1/', + + flagsmithClientEventsAPI: 'https://events.bullet-train-staging.win/v1/events', // This is used for Sentry tracking maintenance: false, plans: { From b3fcd38e379ed2d8d5c4a6ef6078645cd2a699dd Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 2 Jun 2026 16:29:40 +0200 Subject: [PATCH 3/5] feat(experimentation): add warehouse test event and polling helpers --- .../__tests__/sendWarehouseTestEvent.test.ts | 41 +++++++++++++++++++ .../__tests__/warehousePolling.test.ts | 23 +++++++++++ .../warehouse-tab/sendWarehouseTestEvent.ts | 32 +++++++++++++++ .../tabs/warehouse-tab/warehousePolling.ts | 9 ++++ 4 files changed, 105 insertions(+) create mode 100644 frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/sendWarehouseTestEvent.test.ts create mode 100644 frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/warehousePolling.test.ts create mode 100644 frontend/web/components/pages/environment-settings/tabs/warehouse-tab/sendWarehouseTestEvent.ts create mode 100644 frontend/web/components/pages/environment-settings/tabs/warehouse-tab/warehousePolling.ts diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/sendWarehouseTestEvent.test.ts b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/sendWarehouseTestEvent.test.ts new file mode 100644 index 000000000000..1cfd252edaa2 --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/sendWarehouseTestEvent.test.ts @@ -0,0 +1,41 @@ +import sendWarehouseTestEvent from 'components/pages/environment-settings/tabs/warehouse-tab/sendWarehouseTestEvent' + +const init = jest.fn().mockResolvedValue(undefined) +const trackEvent = jest.fn() + +jest.mock('@flagsmith/flagsmith/isomorphic', () => ({ + createFlagsmithInstance: () => ({ init, trackEvent }), +})) + +jest.mock('common/project', () => ({ + __esModule: true, + default: { api: 'http://localhost:8000/api/v1/' }, +})) + +describe('sendWarehouseTestEvent', () => { + beforeEach(() => { + init.mockClear() + trackEvent.mockClear() + }) + + it('inits a per-environment instance with events enabled and no flag fetch', async () => { + await sendWarehouseTestEvent('env-key-123') + + expect(init).toHaveBeenCalledWith( + expect.objectContaining({ + defaultFlags: {}, + enableEvents: true, + environmentID: 'env-key-123', + preventFetch: true, + }), + ) + }) + + it('tracks the test_custom_event after init', async () => { + await sendWarehouseTestEvent('env-key-123') + + expect(trackEvent).toHaveBeenCalledWith('test_custom_event') + expect(init).toHaveBeenCalledTimes(1) + expect(trackEvent).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/warehousePolling.test.ts b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/warehousePolling.test.ts new file mode 100644 index 000000000000..8af164e08e1e --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/warehousePolling.test.ts @@ -0,0 +1,23 @@ +import { getWarehousePollingInterval } from 'components/pages/environment-settings/tabs/warehouse-tab/warehousePolling' + +describe('getWarehousePollingInterval', () => { + it('polls every 30s while pending_connection', () => { + expect(getWarehousePollingInterval('pending_connection')).toBe(30000) + }) + + it('does not poll for connected', () => { + expect(getWarehousePollingInterval('connected')).toBe(0) + }) + + it('does not poll for created', () => { + expect(getWarehousePollingInterval('created')).toBe(0) + }) + + it('does not poll for errored', () => { + expect(getWarehousePollingInterval('errored')).toBe(0) + }) + + it('does not poll when status is undefined', () => { + expect(getWarehousePollingInterval(undefined)).toBe(0) + }) +}) diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/sendWarehouseTestEvent.ts b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/sendWarehouseTestEvent.ts new file mode 100644 index 000000000000..fe53c8dc4a05 --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/sendWarehouseTestEvent.ts @@ -0,0 +1,32 @@ +import { createFlagsmithInstance } from '@flagsmith/flagsmith/isomorphic' +import Project from 'common/project' + +// Emits a throwaway "test_custom_event" tagged with the target environment's +// client-side key, so the user can verify their warehouse connection. A +// dedicated instance is used because the dashboard's global Flagsmith client is +// keyed to Flagsmith's own environment, not the customer's. +// +// We only want to emit an event, never evaluate flags. `preventFetch` with an +// empty `defaultFlags` skips fetching the customer's flag configuration +// entirely (so we never pull their config into the dashboard), while +// `enableEvents` still starts the event pipeline. `api` targets the backend +// this environment lives on, and `eventsApiUrl` (when configured) points the +// event pipeline at the matching ingestor; otherwise the SDK default is used. +const sendWarehouseTestEvent = async (environmentId: string): Promise => { + const instance = createFlagsmithInstance() + await instance.init({ + api: Project.api, + defaultFlags: {}, + enableEvents: true, + environmentID: environmentId, + preventFetch: true, + ...(Project.flagsmithClientEventsAPI && { + eventProcessorConfig: { + eventsApiUrl: Project.flagsmithClientEventsAPI, + }, + }), + }) + instance.trackEvent('test_custom_event') +} + +export default sendWarehouseTestEvent diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/warehousePolling.ts b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/warehousePolling.ts new file mode 100644 index 000000000000..488c5324abda --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/warehousePolling.ts @@ -0,0 +1,9 @@ +import { WarehouseConnectionStatus } from 'common/types/responses' + +export const WAREHOUSE_POLL_INTERVAL_MS = 30000 + +// RTK Query treats a pollingInterval of 0 as "do not poll". We only poll while +// the backend is waiting for the first event to land in the warehouse. +export const getWarehousePollingInterval = ( + status: WarehouseConnectionStatus | undefined, +): number => (status === 'pending_connection' ? WAREHOUSE_POLL_INTERVAL_MS : 0) From 4d87e7f3d02ce9a32521c22477d235e44ba0441c Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 2 Jun 2026 16:32:12 +0200 Subject: [PATCH 4/5] feat(experimentation): wire warehouse test connection polling and stats ui --- .../warehouse-tab/WarehouseConnectionCard.tsx | 47 ++++++++++++++++--- .../tabs/warehouse-tab/index.tsx | 33 ++++++++++++- 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseConnectionCard.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseConnectionCard.tsx index a624a18f7c58..2b704083a98f 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseConnectionCard.tsx +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseConnectionCard.tsx @@ -15,6 +15,8 @@ type WarehouseConnectionCardProps = { connection: WarehouseConnection onDelete: () => void onEdit?: () => void + onSendTestEvent: () => void + isSendingTestEvent: boolean } const STATUS_COLOUR: Record = { @@ -38,14 +40,20 @@ const TYPE_LABEL: Partial> = { const WarehouseConnectionCard: FC = ({ connection, + isSendingTestEvent, onDelete, onEdit, + onSendTestEvent, }) => { const typeLabel = connection.warehouse_type !== 'flagsmith' ? TYPE_LABEL[connection.warehouse_type] ?? connection.warehouse_type : null + const isFlagsmith = connection.warehouse_type === 'flagsmith' + const isPending = connection.status === 'pending_connection' + const isConnected = connection.status === 'connected' + const handleDelete = () => { openConfirm({ body: 'Are you sure you want to remove this warehouse connection?', @@ -106,21 +114,46 @@ const WarehouseConnectionCard: FC = ({
+ {connection.status === 'pending_connection' && ( +
+ + + Your test event is on its way. It can take up to a few hours to + process the first event. + +
+ )}
- {connection.warehouse_type === 'flagsmith' ? ( - - ) : ( + {!isFlagsmith && ( )} + {isFlagsmith && isPending && ( + + )} + {isFlagsmith && !isPending && !isConnected && ( + + )}
) diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx index fd32a646c812..89acdd816537 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx @@ -1,8 +1,9 @@ -import { FC, useState } from 'react' +import { FC, useEffect, useState } from 'react' import { useCreateWarehouseConnectionMutation, useDeleteWarehouseConnectionMutation, useGetWarehouseConnectionsQuery, + useTestWarehouseConnectionMutation, useUpdateWarehouseConnectionMutation, } from 'common/services/useWarehouseConnection' import { SnowflakeConfig } from 'common/types/responses' @@ -10,6 +11,8 @@ import Loader from 'components/Loader' import WarehouseConnectionCard from './WarehouseConnectionCard' import WarehouseSetup from './WarehouseSetup' import ConfigForm from './ConfigForm' +import sendWarehouseTestEvent from './sendWarehouseTestEvent' +import { getWarehousePollingInterval } from './warehousePolling' type WarehouseTabProps = { environmentId: string @@ -18,13 +21,18 @@ type WarehouseTabProps = { const WarehouseTab: FC = ({ environmentId }) => { const [editing, setEditing] = useState(false) + // Poll only while waiting for the first event to land in the warehouse. Held + // in state (updated by the effect below) because the interval depends on the + // query's own result, which isn't available when the hook is first called. + const [pollingInterval, setPollingInterval] = useState(0) + const { data: connections, isError, isLoading, } = useGetWarehouseConnectionsQuery( { environmentId }, - { skip: !environmentId }, + { pollingInterval, skip: !environmentId }, ) const [createConnection, { isLoading: isCreating }] = useCreateWarehouseConnectionMutation() @@ -33,6 +41,13 @@ const WarehouseTab: FC = ({ environmentId }) => { const connection = connections?.[0] + useEffect(() => { + setPollingInterval(getWarehousePollingInterval(connection?.status)) + }, [connection?.status]) + + const [testConnection, { isLoading: isSendingTestEvent }] = + useTestWarehouseConnectionMutation() + const handleEnableFlagsmith = () => { openConfirm({ body: 'This will enable a Flagsmith Warehouse connection for this environment. Are you sure you want to proceed?', @@ -88,6 +103,18 @@ const WarehouseTab: FC = ({ environmentId }) => { .catch(() => toast('Failed to remove warehouse connection', 'danger')) } + const handleSendTestEvent = () => { + if (!connection) return + sendWarehouseTestEvent(environmentId) + .then(() => testConnection({ environmentId, id: connection.id }).unwrap()) + .then(() => toast('Test event sent')) + .catch((error) => { + // eslint-disable-next-line no-console + console.error('[warehouse] send test event failed:', error) + toast('Failed to send test event', 'danger') + }) + } + if (isLoading) { return (
@@ -142,6 +169,8 @@ const WarehouseTab: FC = ({ environmentId }) => { ? () => setEditing(true) : undefined } + onSendTestEvent={handleSendTestEvent} + isSendingTestEvent={isSendingTestEvent} />
) From aacbc996d409f6c43f58930ab15025312e844b4c Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 2 Jun 2026 16:32:54 +0200 Subject: [PATCH 5/5] feat(experimentation): hide code snippet docs --- frontend/web/components/CodeHelp.tsx | 9 +++++++-- .../tabs/warehouse-tab/WarehouseEventCodeHelp.tsx | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/web/components/CodeHelp.tsx b/frontend/web/components/CodeHelp.tsx index c0ebc979e679..65fb0db586ad 100644 --- a/frontend/web/components/CodeHelp.tsx +++ b/frontend/web/components/CodeHelp.tsx @@ -8,6 +8,7 @@ import Icon from './icons/Icon' type Snippets = Record type CodeHelpProps = { + hideDocs?: boolean hideHeader?: boolean showInitially?: boolean snippets: Snippets @@ -22,6 +23,7 @@ type LanguageOption = { type SnippetItemProps = { code: string + hideDocs?: boolean isVisible: boolean language: string languageKey: string @@ -108,6 +110,7 @@ const getDocsLink = (key: string): string | null => { const SnippetItem: FC = ({ code, + hideDocs, isVisible, language, languageKey, @@ -115,8 +118,8 @@ const SnippetItem: FC = ({ onCopy, onLanguageChange, }) => { - const docs = getDocsLink(languageKey) - const github = getGithubLink(languageKey) + const docs = hideDocs ? null : getDocsLink(languageKey) + const github = hideDocs ? null : getGithubLink(languageKey) return (
@@ -185,6 +188,7 @@ const SnippetItem: FC = ({ } const CodeHelp: FC = ({ + hideDocs, hideHeader, showInitially, snippets, @@ -251,6 +255,7 @@ const CodeHelp: FC = ({ ( snippets={enabledSnippets} showInitially hideHeader + hideDocs />
)