diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index 270a1d31fb582..460e816fb78a3 100644 --- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -4,7 +4,7 @@ import Link from 'next/link' import { useState } from 'react' import { toast } from 'sonner' -import { useFlag, useParams } from 'common' +import { useParams } from 'common' import { RefreshButton } from 'components/grid/components/header/RefreshButton' import { getEntityLintDetails } from 'components/interfaces/TableGridEditor/TableEntity.utils' import { APIDocsButton } from 'components/ui/APIDocsButton' @@ -22,13 +22,13 @@ import { isView as isTableLikeView, } from 'data/table-editor/table-editor-types' import { useTableUpdateMutation } from 'data/tables/table-update-mutation' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { RealtimeButtonVariant, useRealtimeExperiment } from 'hooks/misc/useRealtimeExperiment' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' import { DOCS_URL } from 'lib/constants' +import { useTrack } from 'lib/telemetry/track' import { parseAsBoolean, useQueryState } from 'nuqs' import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' import { @@ -54,7 +54,7 @@ export interface GridHeaderActionsProps { export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProps) => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() - const { data: org } = useSelectedOrganizationQuery() + const track = useTrack() const [showWarning, setShowWarning] = useQueryState( 'showWarning', @@ -69,7 +69,6 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp const isView = isTableLikeView(table) const isMaterializedView = isTableLikeMaterializedView(table) - const triggersInsteadOfRealtime = useFlag('triggersInsteadOfRealtime') const { realtimeAll: realtimeEnabled } = useIsFeatureEnabled(['realtime:all']) const { isSchemaLocked } = useIsProtectedSchema({ schema: table.schema }) @@ -106,12 +105,24 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp (publication) => publication.name === 'supabase_realtime' ) const realtimeEnabledTables = realtimePublication?.tables ?? [] - const isRealtimeEnabled = realtimeEnabledTables.some((t: any) => t.id === table?.id) + const isRealtimeEnabled = realtimeEnabledTables.some((t) => t.id === table?.id) + + const { activeVariant: activeRealtimeVariant } = useRealtimeExperiment({ + projectInsertedAt: project?.inserted_at, + isTable, + isRealtimeEnabled, + }) const { mutate: updatePublications, isLoading: isTogglingRealtime } = useDatabasePublicationUpdateMutation({ onSuccess: () => { setShowEnableRealtime(false) + + track(isRealtimeEnabled ? 'table_realtime_disabled' : 'table_realtime_enabled', { + method: 'ui', + schema_name: table.schema, + table_name: table.name, + }) }, onError: (error) => { toast.error(`Failed to toggle realtime for ${table.name}: ${error.message}`) @@ -162,33 +173,21 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp table.schema ) - const { mutate: sendEvent } = useSendEventMutation() - const manageTriggersHref = `/project/${ref}/database/triggers?schema=${table.schema}` const toggleRealtime = async () => { - if (!project) return console.error('Project is required') - if (!realtimePublication) return console.error('Unable to find realtime publication') + if (!project || !realtimePublication) return - const exists = realtimeEnabledTables.some((x: any) => x.id == table.id) + const exists = realtimeEnabledTables.some((x) => x.id === table.id) const tables = !exists ? [`${table.schema}.${table.name}`].concat( - realtimeEnabledTables.map((t: any) => `${t.schema}.${t.name}`) + realtimeEnabledTables.map((t) => `${t.schema}.${t.name}`) ) - : realtimeEnabledTables - .filter((x: any) => x.id != table.id) - .map((x: any) => `${x.schema}.${x.name}`) - - sendEvent({ - action: 'realtime_toggle_table_clicked', - properties: { - newState: exists ? 'disabled' : 'enabled', - origin: 'tableGridHeader', - }, - groups: { - project: project?.ref ?? 'Unknown', - organization: org?.slug ?? 'Unknown', - }, + : realtimeEnabledTables.filter((x) => x.id !== table.id).map((x) => `${x.schema}.${x.name}`) + + track('realtime_toggle_table_clicked', { + newState: exists ? 'disabled' : 'enabled', + origin: 'tableGridHeader', }) updatePublications({ @@ -217,17 +216,10 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp payload: payload, }) - sendEvent({ - action: 'table_rls_enabled', - properties: { - method: 'table_editor', - schema_name: table.schema, - table_name: table.name, - }, - groups: { - project: projectRef, - ...(org?.slug && { organization: org.slug }), - }, + track('table_rls_enabled', { + method: 'table_editor', + schema_name: table.schema, + table_name: table.name, }) } @@ -344,7 +336,7 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp ) ) : null} - {isTable && triggersInsteadOfRealtime ? ( + {isTable && activeRealtimeVariant === RealtimeButtonVariant.TRIGGERS ? ( ) : ( + activeRealtimeVariant !== RealtimeButtonVariant.HIDE_BUTTON && realtimeEnabled && ( (false) @@ -397,6 +399,12 @@ export const SidePanelEditor = ({ publish_delete: true, tables: realtimeTables, }) + + track(enabled ? 'table_realtime_enabled' : 'table_realtime_disabled', { + method: 'ui', + schema_name: table.schema, + table_name: table.name, + }) return } if (realtimePublication.tables === null) { @@ -425,6 +433,12 @@ export const SidePanelEditor = ({ connectionString: project.connectionString, tables: realtimeTables, }) + + track(enabled ? 'table_realtime_enabled' : 'table_realtime_disabled', { + method: 'ui', + schema_name: table.schema, + table_name: table.name, + }) return } const isAlreadyEnabled = realtimePublication.tables.some((x) => x.id == table.id) @@ -447,6 +461,12 @@ export const SidePanelEditor = ({ connectionString: project.connectionString, tables: realtimeTables, }) + + track(enabled ? 'table_realtime_enabled' : 'table_realtime_disabled', { + method: 'ui', + schema_name: table.schema, + table_name: table.name, + }) } catch (error: any) { toast.error(`Failed to update realtime for ${table.name}: ${error.message}`) } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx index d0679345f4f15..6781e0aa7791a 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx @@ -8,11 +8,11 @@ import { useDatabasePublicationsQuery } from 'data/database-publications/databas import { CONSTRAINT_TYPE, useTableConstraintsQuery } from 'data/database/constraints-query' import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query' import { useEnumeratedTypesQuery } from 'data/enumerated-types/enumerated-types-query' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useTrack } from 'lib/telemetry/track' import { useCustomContent } from 'hooks/custom-content/useCustomContent' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { useRealtimeExperiment, RealtimeButtonVariant } from 'hooks/misc/useRealtimeExperiment' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useUrlState } from 'hooks/ui/useUrlState' import { useProtectedSchemas } from 'hooks/useProtectedSchemas' @@ -72,14 +72,12 @@ export const TableEditor = ({ const snap = useTableEditorStateSnapshot() const { data: project } = useSelectedProjectQuery() - const { data: org } = useSelectedOrganizationQuery() const { selectedSchema } = useQuerySchemaState() - + const isNewRecord = isUndefined(table) const { realtimeAll: realtimeEnabled } = useIsFeatureEnabled(['realtime:all']) - const { docsRowLevelSecurityGuidePath } = useCustomContent(['docs:row_level_security_guide_path']) - const { mutate: sendEvent } = useSendEventMutation() + const track = useTrack() - const isNewRecord = isUndefined(table) + const { docsRowLevelSecurityGuidePath } = useCustomContent(['docs:row_level_security_guide_path']) const [params, setParams] = useUrlState() useEffect(() => { @@ -110,6 +108,12 @@ export const TableEditor = ({ ? false : realtimeEnabledTables.some((t) => t.id === table?.id) + const { activeVariant: activeRealtimeVariant } = useRealtimeExperiment({ + projectInsertedAt: project?.inserted_at, + isTable: !isNewRecord, + isRealtimeEnabled, + }) + const [errors, setErrors] = useState({}) const [tableFields, setTableFields] = useState() const [fkRelations, setFkRelations] = useState([]) @@ -396,23 +400,16 @@ export const TableEditor = ({ )} - {realtimeEnabled && ( + {activeRealtimeVariant !== RealtimeButtonVariant.HIDE_BUTTON && realtimeEnabled && ( { - sendEvent({ - action: 'realtime_toggle_table_clicked', - properties: { - newState: tableFields.isRealtimeEnabled ? 'disabled' : 'enabled', - origin: 'tableSidePanel', - }, - groups: { - project: project?.ref ?? 'Unknown', - organization: org?.slug ?? 'Unknown', - }, + track('realtime_toggle_table_clicked', { + newState: tableFields.isRealtimeEnabled ? 'disabled' : 'enabled', + origin: 'tableSidePanel', }) onUpdateField({ isRealtimeEnabled: !tableFields.isRealtimeEnabled }) }} diff --git a/apps/studio/hooks/misc/useRealtimeExperiment.ts b/apps/studio/hooks/misc/useRealtimeExperiment.ts new file mode 100644 index 0000000000000..aca021b312fe6 --- /dev/null +++ b/apps/studio/hooks/misc/useRealtimeExperiment.ts @@ -0,0 +1,112 @@ +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' +import { useEffect, useMemo, useRef } from 'react' + +import { usePHFlag } from 'hooks/ui/useFlag' +import { IS_PLATFORM } from 'lib/constants' +import { useTrack } from 'lib/telemetry/track' + +dayjs.extend(utc) + +/** + * Days after project creation to be considered "new" for experiment targeting + */ +export const NEW_PROJECT_THRESHOLD_DAYS = 7 + +export enum RealtimeButtonVariant { + CONTROL = 'control', + HIDE_BUTTON = 'hide-button', + TRIGGERS = 'triggers', +} + +interface UseRealtimeExperimentOptions { + /** + * Project creation timestamp + */ + projectInsertedAt?: string + /** + * Whether the current context is a table (not a view/foreign table) + */ + isTable?: boolean + /** + * Whether realtime is currently enabled for the table + */ + isRealtimeEnabled?: boolean +} + +interface UseRealtimeExperimentResult { + /** + * The active variant for this user/project, or null if not in experiment + */ + activeVariant: RealtimeButtonVariant | null + /** + * Whether this project is considered "new" for experiment targeting + */ + isNewProject: boolean +} + +/** + * Hook to manage the realtime button A/B experiment logic. + * Handles variant determination, exposure tracking, and date validation. + * + * @param options Configuration for experiment targeting + * @returns Experiment state including active variant and project age + */ +export function useRealtimeExperiment({ + projectInsertedAt, + isTable = false, + isRealtimeEnabled = false, +}: UseRealtimeExperimentOptions): UseRealtimeExperimentResult { + const track = useTrack() + const realtimeButtonVariant = usePHFlag('realtimeButtonVariant') + const hasTrackedExposure = useRef(false) + + const isNewProject = useMemo(() => { + if (!projectInsertedAt) return false + + const insertedDate = dayjs.utc(projectInsertedAt) + if (!insertedDate.isValid()) { + return false + } + + return dayjs.utc().diff(insertedDate, 'day') < NEW_PROJECT_THRESHOLD_DAYS + }, [projectInsertedAt]) + + const activeVariant = useMemo(() => { + if (!IS_PLATFORM) return null + if (!isTable || !isNewProject) return null + if (!realtimeButtonVariant || realtimeButtonVariant === RealtimeButtonVariant.CONTROL) { + return null + } + return realtimeButtonVariant + }, [isTable, isNewProject, realtimeButtonVariant]) + + useEffect(() => { + if (!IS_PLATFORM) return + if (hasTrackedExposure.current) return + if (!isTable || !isNewProject || !projectInsertedAt) return + if (!realtimeButtonVariant) return + + hasTrackedExposure.current = true + + try { + const insertedDate = dayjs.utc(projectInsertedAt) + if (!insertedDate.isValid()) return + + const daysSinceCreation = dayjs.utc().diff(insertedDate, 'day') + + track('realtime_experiment_exposed', { + variant: realtimeButtonVariant, + table_has_realtime_enabled: isRealtimeEnabled, + days_since_project_creation: daysSinceCreation, + }) + } catch { + hasTrackedExposure.current = false + } + }, [isTable, isNewProject, realtimeButtonVariant, projectInsertedAt, isRealtimeEnabled, track]) + + return { + activeVariant, + isNewProject, + } +} diff --git a/apps/studio/hooks/ui/useFlag.ts b/apps/studio/hooks/ui/useFlag.ts index 3ad15f0cc5ede..55f9704f0eb54 100644 --- a/apps/studio/hooks/ui/useFlag.ts +++ b/apps/studio/hooks/ui/useFlag.ts @@ -8,7 +8,32 @@ const isObjectEmpty = (obj: Object) => { return Object.keys(obj).length === 0 } -// TODO(Alaister): move this to packages/common/feature-flags.tsx and rename to useFlag +/** + * Hook to retrieve a PostHog feature flag value. + * + * @returns `undefined | false | T` where: + * - `undefined` = PostHog store is still loading OR flag doesn't exist (treat as "don't show") + * - `false` = Flag is explicitly set to false (typically means "disabled" or "control" for experiments) + * - `T` = The actual flag value (string, boolean, or custom type like variant names) + * + * @example Experiment usage convention: + * ```typescript + * const variant = usePHFlag('experimentName') + * + * // undefined = loading/doesn't exist, don't render anything yet + * if (variant === undefined) return null + * + * // false = explicitly disabled, show control + * if (variant === false) return + * + * // Otherwise, variant has a value, show experiment + * return + * ``` + * + * @todo TODO(Alaister): move this to packages/common/feature-flags.tsx and rename to useFlag + * @todo TODO(sean): Refactor to have explicit loading/disabled/value states + * See https://linear.app/supabase/issue/GROWTH-539 + */ export function usePHFlag(name: string) { const flagStore = useFeatureFlags() // [Joshen] Prepend PH flags with "PH" in local storage for easier identification of PH flags diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 3ac053a937936..c191b2fb6a9f9 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -273,6 +273,7 @@ services: POSTGREST_URL: http://rest:3000 PGRST_JWT_SECRET: ${JWT_SECRET} DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + REQUEST_ALLOW_X_FORWARDED_PATH: "true" FILE_SIZE_LIMIT: 52428800 STORAGE_BACKEND: file FILE_STORAGE_BACKEND_PATH: /var/lib/storage diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index bdbd933725d6f..b7974f98d85e9 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -422,6 +422,58 @@ export interface RealtimeToggleTableClickedEvent { groups: TelemetryGroups } +/** + * Realtime was enabled on a table. + * + * @group Events + * @source studio + * @page /dashboard/project/{ref}/editor + */ +export interface TableRealtimeEnabledEvent { + action: 'table_realtime_enabled' + properties: { + /** + * The method used to enable realtime + */ + method: 'ui' | 'sql_editor' | 'api' + /** + * Schema name + */ + schema_name: string + /** + * Table name + */ + table_name: string + } + groups: TelemetryGroups +} + +/** + * Realtime was disabled on a table. + * + * @group Events + * @source studio + * @page /dashboard/project/{ref}/editor + */ +export interface TableRealtimeDisabledEvent { + action: 'table_realtime_disabled' + properties: { + /** + * The method used to disable realtime + */ + method: 'ui' | 'sql_editor' | 'api' + /** + * Schema name + */ + schema_name: string + /** + * Table name + */ + table_name: string + } + groups: TelemetryGroups +} + /** * User clicked the quickstart card in the SQL editor. * @@ -1546,6 +1598,32 @@ export interface HomeAdvisorAskAssistantClickedEvent { groups: TelemetryGroups } +/** + * User was exposed to the realtime experiment (shown or not shown the Enable Realtime button). + * + * @group Events + * @source studio + * @page /dashboard/project/{ref}/editor + */ +export interface RealtimeExperimentExposedEvent { + action: 'realtime_experiment_exposed' + properties: { + /** + * The experiment variant shown to the user + */ + variant: 'control' | 'hide-button' | 'triggers' + /** + * Whether the table already has realtime enabled + */ + table_has_realtime_enabled: boolean + /** + * Days since project creation (to segment by new user cohorts) + */ + days_since_project_creation: number + } + groups: TelemetryGroups +} + /** * User clicked on an issue card in the Advisor section of HomeV2. * @@ -2266,6 +2344,8 @@ export type TelemetryEvent = | RealtimeInspectorFiltersAppliedEvent | RealtimeInspectorDatabaseRoleUpdatedEvent | RealtimeToggleTableClickedEvent + | TableRealtimeEnabledEvent + | TableRealtimeDisabledEvent | SqlEditorQuickstartClickedEvent | SqlEditorTemplateClickedEvent | SqlEditorResultDownloadCsvClickedEvent @@ -2337,6 +2417,7 @@ export type TelemetryEvent = | HomeAdvisorAskAssistantClickedEvent | HomeAdvisorIssueCardClickedEvent | HomeAdvisorFixIssueClickedEvent + | RealtimeExperimentExposedEvent | HomeProjectUsageServiceClickedEvent | HomeProjectUsageChartClickedEvent | HomeCustomReportBlockAddedEvent