diff --git a/apps/docs/features/app.providers.tsx b/apps/docs/features/app.providers.tsx index 8513316a789..ed5654a9f59 100644 --- a/apps/docs/features/app.providers.tsx +++ b/apps/docs/features/app.providers.tsx @@ -1,9 +1,10 @@ import { type PropsWithChildren } from 'react' -import { ThemeProvider } from 'common' +import { FeatureFlagProvider, ThemeProvider } from 'common' import { SonnerToaster } from 'ui' import { CommandProvider } from 'ui-patterns/CommandMenu' import SiteLayout from '~/layouts/SiteLayout' +import { API_URL } from '~/lib/constants' import { AuthContainer } from './auth/auth.client' import { DocsCommandMenu } from './command' import { QueryClientProvider } from './data/queryClient.client' @@ -18,20 +19,22 @@ function GlobalProviders({ children }: PropsWithChildren) { return ( - - - - -
- - {children} - - - -
-
- -
+ + + + + +
+ + {children} + + + +
+
+ +
+
) diff --git a/apps/docs/features/telemetry/telemetry.client.tsx b/apps/docs/features/telemetry/telemetry.client.tsx index 5264e668827..ee1853b8d74 100644 --- a/apps/docs/features/telemetry/telemetry.client.tsx +++ b/apps/docs/features/telemetry/telemetry.client.tsx @@ -1,86 +1,19 @@ 'use client' -import { isBrowser, useTelemetryCookie } from 'common' -import { usePathname } from 'next/navigation' -import { useCallback, useEffect } from 'react' +import { IS_PLATFORM, PageTelemetry as PageTelemetryImpl } from 'common' import { useConsent } from 'ui-patterns/ConsentToast' -import { IS_PLATFORM } from '~/lib/constants' -import { unauthedAllowedPost } from '~/lib/fetch/fetchWrappers' - -const useTelemetryWithConsent = () => { - const pathname = usePathname() - const { hasAcceptedConsent } = useConsent() - - const sendPageTelemetry = useCallback(() => { - if (!(IS_PLATFORM && hasAcceptedConsent)) return - - const title = typeof document !== 'undefined' ? document?.title : '' - const referrer = typeof document !== 'undefined' ? document?.referrer : '' - - unauthedAllowedPost('/platform/telemetry/page', { - body: { - pathname, - page_url: isBrowser ? window.location.href : '', - page_title: title, - ph: { - referrer, - language: navigator.language ?? 'en-US', - user_agent: navigator.userAgent, - search: isBrowser ? window.location.search : '', - viewport_height: isBrowser ? window.innerHeight : 0, - viewport_width: isBrowser ? window.innerWidth : 0, - }, - }, - headers: { Version: '2' }, - credentials: 'include', - }).catch((e) => { - console.error('Problem sending telemetry page:', e) - }) - }, [pathname, hasAcceptedConsent]) - - const sendPageLeaveTelemetry = useCallback(() => { - if (!(IS_PLATFORM && hasAcceptedConsent)) return - - const title = typeof document !== 'undefined' ? document?.title : '' - - unauthedAllowedPost('/platform/telemetry/page-leave', { - body: { - pathname, - page_url: isBrowser ? window.location.href : '', - page_title: title, - }, - credentials: 'include', - }).catch((e) => { - console.error('Problem sending telemetry page-leave:', e) - }) - }, [pathname, hasAcceptedConsent]) - - return { sendPageTelemetry, sendPageLeaveTelemetry } -} +import { API_URL } from '~/lib/constants' const PageTelemetry = () => { - const pathname = usePathname() - - const title = typeof document !== 'undefined' ? document?.title : '' - const referrer = typeof document !== 'undefined' ? document?.referrer : '' const { hasAcceptedConsent } = useConsent() - useTelemetryCookie({ hasAcceptedConsent, title, referrer }) - const { sendPageTelemetry, sendPageLeaveTelemetry } = useTelemetryWithConsent() - - useEffect(() => { - if (pathname) sendPageTelemetry() - }, [pathname, sendPageTelemetry]) - - useEffect(() => { - if (!pathname) return - - const handleBeforeUnload = () => sendPageLeaveTelemetry() - window.addEventListener('beforeunload', handleBeforeUnload) - return () => window.removeEventListener('beforeunload', handleBeforeUnload) - }, [pathname, sendPageLeaveTelemetry]) - - return null + return ( + + ) } export { PageTelemetry } diff --git a/apps/docs/lib/constants.ts b/apps/docs/lib/constants.ts index fd52e23ad1d..60b43f4d101 100644 --- a/apps/docs/lib/constants.ts +++ b/apps/docs/lib/constants.ts @@ -1,7 +1,7 @@ export const API_URL = ( process.env.NODE_ENV === 'development' ? process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080' - : process.env.NEXT_PUBLIC_API_URL + : process.env.NEXT_PUBLIC_API_URL! )?.replace(/\/platform$/, '') export const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || '/docs' export const IS_DEV = process.env.NODE_ENV === 'development' diff --git a/apps/studio/components/ui/Flag/FlagContext.ts b/apps/studio/components/ui/Flag/FlagContext.ts deleted file mode 100644 index a848bb0f63e..00000000000 --- a/apps/studio/components/ui/Flag/FlagContext.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createContext } from 'react' - -const FlagContext = createContext({ configcat: {}, posthog: {} }) - -export default FlagContext diff --git a/apps/studio/components/ui/Flag/FlagProvider.tsx b/apps/studio/components/ui/Flag/FlagProvider.tsx deleted file mode 100644 index 317af7401d4..00000000000 --- a/apps/studio/components/ui/Flag/FlagProvider.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import type { User } from '@supabase/supabase-js' -import { FlagValues } from '@vercel/flags/react' -import { PropsWithChildren, useEffect, useState } from 'react' - -import { useUser } from 'lib/auth' -import { getFlags } from 'lib/configcat' -import { IS_PLATFORM } from 'lib/constants' -import { CallFeatureFlagsResponse, getPHFeatureFlags } from 'lib/posthog' -import FlagContext from './FlagContext' - -var getCookies = function () { - var pairs = document.cookie.split(';') - var cookies: Record = {} - for (var i = 0; i < pairs.length; i++) { - var [t_key, value] = pairs[i].split('=') - const key = t_key.trim() - - cookies[key] = unescape(value) - } - return cookies -} - -export type FlagProviderStore = { - configcat: { [key: string]: boolean | string } - posthog: CallFeatureFlagsResponse -} - -const FlagProvider = ({ children }: PropsWithChildren<{}>) => { - const user = useUser() - - const { Provider } = FlagContext - const [store, setStore] = useState({ configcat: {}, posthog: {} }) - - const processFlags = async (user?: User) => { - const flagStore: FlagProviderStore = { configcat: {}, posthog: {} } - - // Load PH flags - if (user) { - const flags = await getPHFeatureFlags() - if (flags) flagStore.posthog = flags - } - - // Load ConfigCat flags - const flagValues = await getFlags(user) - let overridesCookieValue: Record = {} - try { - const cookies = getCookies() - overridesCookieValue = JSON.parse(cookies['vercel-flag-overrides']) - } catch {} - - flagValues.forEach((item) => { - flagStore['configcat'][item.settingKey] = - overridesCookieValue[item.settingKey] ?? item.settingValue - }) - setStore(flagStore) - } - - useEffect(() => { - // [Joshen] getFlags get triggered everytime the tab refocuses but this should be okay - // as per https://configcat.com/docs/sdk-reference/js/#polling-modes: - // The polling downloads the config.json at the set interval and are stored in the internal cache - // which subsequently all getValueAsync() calls are served from there - if (IS_PLATFORM) processFlags(user ?? undefined) - }, [user]) - - return ( - - {/* - [Joshen] Just support configcat flags in Vercel flags for now for simplicity - although I think it should be fairly simply to support PH too - */} - - {children} - - ) -} - -export default FlagProvider diff --git a/apps/studio/components/ui/PageTelemetry.tsx b/apps/studio/components/ui/GroupsTelemetry.tsx similarity index 64% rename from apps/studio/components/ui/PageTelemetry.tsx rename to apps/studio/components/ui/GroupsTelemetry.tsx index 0dc86e373e6..5925692f290 100644 --- a/apps/studio/components/ui/PageTelemetry.tsx +++ b/apps/studio/components/ui/GroupsTelemetry.tsx @@ -1,17 +1,15 @@ +/* eslint-disable react-hooks/rules-of-hooks */ import * as Sentry from '@sentry/nextjs' import { useRouter } from 'next/router' -import { PropsWithChildren, useEffect } from 'react' +import { useEffect } from 'react' import { useParams, useTelemetryCookie, useUser } from 'common' import { useSendGroupsIdentifyMutation } from 'data/telemetry/send-groups-identify-mutation' import { useSendGroupsResetMutation } from 'data/telemetry/send-groups-reset-mutation' -import { useSendPageLeaveMutation } from 'data/telemetry/send-page-leave-mutation' -import { useSendPageMutation } from 'data/telemetry/send-page-mutation' import { usePrevious } from 'hooks/deprecated' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' -import { useConsent } from 'ui-patterns/ConsentToast' const getAnonId = async (id: string) => { const encoder = new TextEncoder() @@ -23,19 +21,19 @@ const getAnonId = async (id: string) => { return base64String } -const PageTelemetry = ({ children }: PropsWithChildren<{}>) => { +const GroupsTelemetry = ({ hasAcceptedConsent }: { hasAcceptedConsent: boolean }) => { + // Although this is "technically" breaking the rules of hooks + // IS_PLATFORM never changes within a session, so this won't cause any issues + if (!IS_PLATFORM) return null + const user = useUser() const router = useRouter() const { ref, slug } = useParams() const snap = useAppStateSnapshot() const organization = useSelectedOrganization() - const { consentValue, hasAcceptedConsent } = useConsent() const previousPathname = usePrevious(router.pathname) - const trackTelemetryPH = consentValue === 'true' - const { mutate: sendPage } = useSendPageMutation() - const { mutateAsync: sendPageLeave } = useSendPageLeaveMutation() const { mutate: sendGroupsIdentify } = useSendGroupsIdentifyMutation() const { mutate: sendGroupsReset } = useSendGroupsResetMutation() @@ -44,34 +42,11 @@ const PageTelemetry = ({ children }: PropsWithChildren<{}>) => { useTelemetryCookie({ hasAcceptedConsent, title, referrer }) useEffect(() => { - if (consentValue !== null) { + if (hasAcceptedConsent) { snap.setIsOptedInTelemetry(hasAcceptedConsent) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [consentValue]) - - useEffect(() => { - function handleRouteChange() { - if (snap.isOptedInTelemetry) handlePageTelemetry(window.location.href) - } - - // Listen for page changes after a navigation or when the query changes - router.events.on('routeChangeComplete', handleRouteChange) - return () => { - router.events.off('routeChangeComplete', handleRouteChange) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [router, snap.isOptedInTelemetry]) - - useEffect(() => { - // Send page telemetry on first page load - // Waiting for router ready before sending page_view - // if not the path will be dynamic route instead of the browser url - if (router.isReady && snap.isOptedInTelemetry) { - handlePageTelemetry(window.location.href) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [router.isReady, snap.isOptedInTelemetry]) + }, [hasAcceptedConsent]) useEffect(() => { // don't set the sentry user id if the user hasn't logged in (so that Sentry errors show null user id instead of anonymous id) @@ -107,7 +82,7 @@ const PageTelemetry = ({ children }: PropsWithChildren<{}>) => { const isLeavingOrgRoute = (previousPathname ?? '').includes('[slug]') && !router.pathname.includes('[slug]') - if (trackTelemetryPH) { + if (hasAcceptedConsent) { if (ref && (isLandingOnProjectRoute || isEnteringProjectRoute)) { sendGroupsIdentify({ organization_slug: organization?.slug, project_ref: ref as string }) } else if (slug && (isLandingOnOrgRoute || isEnteringOrgRoute)) { @@ -121,24 +96,9 @@ const PageTelemetry = ({ children }: PropsWithChildren<{}>) => { } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [trackTelemetryPH, slug, ref, router.pathname]) + }, [hasAcceptedConsent, slug, ref, router.pathname]) - useEffect(() => { - const handleBeforeUnload = async () => { - if (snap.isOptedInTelemetry) await sendPageLeave() - } - window.addEventListener('beforeunload', handleBeforeUnload) - - return () => { - window.removeEventListener('beforeunload', handleBeforeUnload) - } - }, []) - - const handlePageTelemetry = async (route: string) => { - if (IS_PLATFORM) sendPage({ url: route }) - } - - return <>{children} + return null } -export default PageTelemetry +export default GroupsTelemetry diff --git a/apps/studio/data/content/content-count-query.ts b/apps/studio/data/content/content-count-query.ts index ac7fc907823..d7c61b9ad39 100644 --- a/apps/studio/data/content/content-count-query.ts +++ b/apps/studio/data/content/content-count-query.ts @@ -28,7 +28,7 @@ export async function getContentCount( if (name) query.name = name const { data, error } = await get('/platform/projects/{ref}/content/count', { - params: { path: { ref: projectRef }, query: { type, visibility, favorite, name } }, + params: { path: { ref: projectRef }, query }, signal, }) diff --git a/apps/studio/data/telemetry/send-event-mutation.ts b/apps/studio/data/telemetry/send-event-mutation.ts index fc9b17384c8..cdf9bcf2b95 100644 --- a/apps/studio/data/telemetry/send-event-mutation.ts +++ b/apps/studio/data/telemetry/send-event-mutation.ts @@ -69,7 +69,7 @@ export type SendEventVariables = | AssistantSqlDiffHandlerEvaluatedEvent | AssistantEditInSqlEditorClickedEvent -type SendEventPayload = components['schemas']['TelemetryEventBodyV2'] +type SendEventPayload = components['schemas']['TelemetryEventBodyV2Dto'] export async function sendEvent({ body }: { body: SendEventPayload }) { const consent = diff --git a/apps/studio/data/telemetry/send-page-leave-mutation.ts b/apps/studio/data/telemetry/send-page-leave-mutation.ts deleted file mode 100644 index 97d3b7cf9e0..00000000000 --- a/apps/studio/data/telemetry/send-page-leave-mutation.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { useMutation, UseMutationOptions } from '@tanstack/react-query' -import { components } from 'api-types' - -import { LOCAL_STORAGE_KEYS } from 'common' -import { handleError, post } from 'data/fetchers' -import { IS_PLATFORM } from 'lib/constants' -import { useRouter } from 'next/router' -import type { ResponseError } from 'types' - -type SendPageLeaveBody = components['schemas']['TelemetryPageLeaveBody'] - -export async function sendPageLeave({ body }: { body: SendPageLeaveBody }) { - const consent = - (typeof window !== 'undefined' - ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) - : null) === 'true' - - if (!consent || !IS_PLATFORM) return undefined - - const { data, error } = await post(`/platform/telemetry/page-leave`, { - body, - credentials: 'include', - }) - if (error) handleError(error) - return data -} - -type SendPageLeaveData = Awaited> - -export const useSendPageLeaveMutation = ({ - onSuccess, - onError, - ...options -}: Omit, 'mutationFn'> = {}) => { - const router = useRouter() - - const url = typeof window !== 'undefined' ? window.location.href : '' - const title = typeof document !== 'undefined' ? document?.title : '' - - const body = { - page_url: url, - page_title: title, - pathname: router.pathname, - } as SendPageLeaveBody - - return useMutation((vars) => sendPageLeave({ body }), { - async onSuccess(data, variables, context) { - await onSuccess?.(data, variables, context) - }, - async onError(data, variables, context) { - if (onError === undefined) { - console.error(`Failed to send Telemetry page leave: ${data.message}`) - } else { - onError(data, variables, context) - } - }, - ...options, - }) -} diff --git a/apps/studio/data/telemetry/send-page-mutation.ts b/apps/studio/data/telemetry/send-page-mutation.ts deleted file mode 100644 index 498b83af048..00000000000 --- a/apps/studio/data/telemetry/send-page-mutation.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useMutation, UseMutationOptions } from '@tanstack/react-query' - -import { components } from 'api-types' -import { isBrowser, LOCAL_STORAGE_KEYS } from 'common' -import { handleError, post } from 'data/fetchers' -import { IS_PLATFORM } from 'lib/constants' -import { useRouter } from 'next/router' -import type { ResponseError } from 'types' - -type SendPage = components['schemas']['TelemetryPageBodyV2'] - -export type SendPageVariables = { - url: string -} - -type SendPagePayload = any - -export async function sendPage({ body }: { body: SendPagePayload }) { - const consent = - (typeof window !== 'undefined' - ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) - : null) === 'true' - - if (!consent || !IS_PLATFORM) return undefined - - const headers = { Version: '2' } - const { data, error } = await post(`/platform/telemetry/page`, { body, headers }) - if (error) handleError(error) - return data -} - -type SendPageData = Awaited> - -export const useSendPageMutation = ({ - onSuccess, - onError, - ...options -}: Omit, 'mutationFn'> = {}) => { - const router = useRouter() - - const title = typeof document !== 'undefined' ? document?.title : '' - const referrer = typeof document !== 'undefined' ? document?.referrer : '' - - return useMutation( - (vars) => { - const { url } = vars - const type = 'PH' - const body: SendPage = { - page_url: url, - page_title: title, - pathname: router.pathname, - ph: { - referrer, - language: router?.locale ?? 'en-US', - user_agent: navigator.userAgent, - search: window.location.search, - viewport_height: isBrowser ? window.innerHeight : 0, - viewport_width: isBrowser ? window.innerWidth : 0, - }, - } - return sendPage({ body }) - }, - { - async onSuccess(data, variables, context) { - await onSuccess?.(data, variables, context) - }, - async onError(data, variables, context) { - if (onError === undefined) { - console.error(`Failed to send Telemetry page: ${data.message}`) - } else { - onError(data, variables, context) - } - }, - ...options, - } - ) -} diff --git a/apps/studio/hooks/ui/useFlag.ts b/apps/studio/hooks/ui/useFlag.ts index 798013f0c99..1a8f85b6047 100644 --- a/apps/studio/hooks/ui/useFlag.ts +++ b/apps/studio/hooks/ui/useFlag.ts @@ -1,8 +1,6 @@ -import { useContext } from 'react' import * as Sentry from '@sentry/nextjs' -import FlagContext from 'components/ui/Flag/FlagContext' -import { FlagProviderStore } from 'components/ui/Flag/FlagProvider' +import { useFeatureFlags } from 'common' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { trackFeatureFlag } from 'lib/posthog' @@ -13,7 +11,7 @@ const isObjectEmpty = (obj: Object) => { export function useFlag(name: string) { const project = useSelectedProject() - const flagStore = useContext(FlagContext) as FlagProviderStore + const flagStore = useFeatureFlags() const store = flagStore.configcat @@ -29,8 +27,9 @@ export function useFlag(name: string) { return store[name] as T } +// TODO(Alaister): move this to packages/common/feature-flags.tsx and rename to useFlag export function usePHFlag(name: string) { - const flagStore = useContext(FlagContext) as FlagProviderStore + const flagStore = useFeatureFlags() // [Joshen] Prepend PH flags with "PH" in local storage for easier identification of PH flags const [trackedValue, setTrackedValue] = useLocalStorageQuery(`ph_${name}`, '') diff --git a/apps/studio/lib/configcat.ts b/apps/studio/lib/configcat.ts index c566a75449a..6f609b50158 100644 --- a/apps/studio/lib/configcat.ts +++ b/apps/studio/lib/configcat.ts @@ -1,4 +1,3 @@ -import type { User } from '@supabase/supabase-js' import * as configcat from 'configcat-js' let client: configcat.IConfigCatClient @@ -17,9 +16,10 @@ function getClient() { return client } -export async function getFlags(user?: User) { - if (user) { - return getClient().getAllValuesAsync(new configcat.User(user?.email ?? '')) +export async function getFlags(userEmail: string = '') { + if (userEmail) { + return getClient().getAllValuesAsync(new configcat.User(userEmail)) } + return [] } diff --git a/apps/studio/lib/constants/index.ts b/apps/studio/lib/constants/index.ts index 4ef94bbce92..bf29f79cefa 100644 --- a/apps/studio/lib/constants/index.ts +++ b/apps/studio/lib/constants/index.ts @@ -4,7 +4,7 @@ export * from './infrastructure' export const IS_PLATFORM = process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' export const DEFAULT_HOME = IS_PLATFORM ? '/projects' : '/project/default' -export const API_URL = IS_PLATFORM ? process.env.NEXT_PUBLIC_API_URL : '/api' +export const API_URL = IS_PLATFORM ? process.env.NEXT_PUBLIC_API_URL! : '/api' export const PG_META_URL = IS_PLATFORM ? process.env.PLATFORM_PG_META_URL : process.env.STUDIO_PG_META_URL diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index 0fee2f145de..0575a04b0c0 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -22,14 +22,14 @@ import { TooltipProvider } from '@radix-ui/react-tooltip' import * as Sentry from '@sentry/nextjs' import { Hydrate, QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' -import { ThemeProvider, useThemeSandbox } from 'common' +import { FeatureFlagProvider, PageTelemetry, ThemeProvider, useThemeSandbox } from 'common' import dayjs from 'dayjs' import customParseFormat from 'dayjs/plugin/customParseFormat' import relativeTime from 'dayjs/plugin/relativeTime' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' import Head from 'next/head' -import { ErrorInfo, useMemo, useState } from 'react' +import { ErrorInfo } from 'react' import { ErrorBoundary } from 'react-error-boundary' import MetaFaviconsPagesRouter from 'common/MetaFavicons/pages-router' @@ -40,15 +40,16 @@ import { FeaturePreviewContextProvider } from 'components/interfaces/App/Feature import FeaturePreviewModal from 'components/interfaces/App/FeaturePreview/FeaturePreviewModal' import { GenerateSql } from 'components/interfaces/SqlGenerator/SqlGenerator' import { ErrorBoundaryState } from 'components/ui/ErrorBoundaryState' -import FlagProvider from 'components/ui/Flag/FlagProvider' -import PageTelemetry from 'components/ui/PageTelemetry' +import GroupsTelemetry from 'components/ui/GroupsTelemetry' import { useRootQueryClient } from 'data/query-client' import { AuthProvider } from 'lib/auth' -import { BASE_PATH, IS_PLATFORM } from 'lib/constants' +import { getFlags as getConfigCatFlags } from 'lib/configcat' +import { API_URL, BASE_PATH, IS_PLATFORM } from 'lib/constants' import { ProfileProvider } from 'lib/profile' import HCaptchaLoadedStore from 'stores/hcaptcha-loaded-store' import { AppPropsWithLayout } from 'types' import { SonnerToaster } from 'ui' +import { useConsent } from 'ui-patterns' import { CommandProvider } from 'ui-patterns/CommandMenu' dayjs.extend(customParseFormat) @@ -79,14 +80,6 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { const getLayout = Component.getLayout ?? ((page) => page) - const TelemetryContainer = useMemo( - // eslint-disable-next-line react/display-name - () => (props: any) => { - return IS_PLATFORM ? {props.children} : <>{props.children} - }, - [] - ) - const errorBoundaryHandler = (error: Error, info: ErrorInfo) => { Sentry.withScope(function (scope) { scope.setTag('globalErrorBoundary', true) @@ -98,6 +91,11 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { useThemeSandbox() + // Although this is "technically" breaking the rules of hooks + // IS_PLATFORM never changes within a session, so this won't cause any issues + // eslint-disable-next-line react-hooks/rules-of-hooks + const { hasAcceptedConsent } = IS_PLATFORM ? useConsent() : { hasAcceptedConsent: true } + const isTestEnv = process.env.NEXT_PUBLIC_NODE_ENV === 'test' return ( @@ -105,44 +103,52 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { - + Supabase - - - - - - - - - {getLayout()} - - - - - - - - - - - - + + + + + + + + {getLayout()} + + + + + + + + + + + + + {!isTestEnv && } {!isTestEnv && } - + diff --git a/apps/studio/pages/api/projects/[ref]/content/count.ts b/apps/studio/pages/api/projects/[ref]/content/count.ts index 2cc917b30f5..568bfd57a06 100644 --- a/apps/studio/pages/api/projects/[ref]/content/count.ts +++ b/apps/studio/pages/api/projects/[ref]/content/count.ts @@ -19,5 +19,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { type ResponseData = extractResponse<'/platform/projects/{ref}/content/count', 'get'> const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { - return res.status(200).json({ count: 1 }) + return res.status(200).json({ + favorites: 0, + private: 0, + shared: 0, + }) } diff --git a/apps/studio/tests/pages/projects/LogsPreviewer.test.tsx b/apps/studio/tests/pages/projects/LogsPreviewer.test.tsx index 88303b214a2..beb0d7e66f3 100644 --- a/apps/studio/tests/pages/projects/LogsPreviewer.test.tsx +++ b/apps/studio/tests/pages/projects/LogsPreviewer.test.tsx @@ -16,6 +16,7 @@ import { render } from '../../helpers' vi.mock('common', () => ({ useParams: vi.fn().mockReturnValue({}), useIsLoggedIn: vi.fn(), + isBrowser: false, })) vi.mock('lib/gotrue', () => ({ auth: { onAuthStateChange: vi.fn() }, diff --git a/apps/www/lib/constants.ts b/apps/www/lib/constants.ts index 9d3e63f6e6e..008cd6e24b6 100644 --- a/apps/www/lib/constants.ts +++ b/apps/www/lib/constants.ts @@ -3,7 +3,7 @@ export const DEFAULT_META_DESCRIPTION = 'Build production-grade applications with a Postgres database, Authentication, instant APIs, Realtime, Functions, Storage and Vector embeddings. Start for free.' export const IS_PROD = process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' export const IS_PREVIEW = process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' -export const API_URL = process.env.NEXT_PUBLIC_API_URL +export const API_URL = process.env.NEXT_PUBLIC_API_URL! // Products diff --git a/apps/www/pages/_app.tsx b/apps/www/pages/_app.tsx index 23ce6964d85..5e10bd6f589 100644 --- a/apps/www/pages/_app.tsx +++ b/apps/www/pages/_app.tsx @@ -4,17 +4,15 @@ import '../styles/index.css' import { AuthProvider, - IS_PROD, + FeatureFlagProvider, + PageTelemetry, ThemeProvider, - useTelemetryCookie, - useTelemetryProps, useThemeSandbox, } from 'common' import { DefaultSeo } from 'next-seo' import { AppProps } from 'next/app' import Head from 'next/head' import { useRouter } from 'next/router' -import { useEffect } from 'react' import { SonnerToaster, themes } from 'ui' import { CommandProvider } from 'ui-patterns/CommandMenu' import { useConsent } from 'ui-patterns/ConsentToast' @@ -24,82 +22,15 @@ import MetaFaviconsPagesRouter, { DEFAULT_FAVICON_THEME_COLOR, } from 'common/MetaFavicons/pages-router' import { WwwCommandMenu } from '~/components/CommandMenu' -import { API_URL, APP_NAME, DEFAULT_META_DESCRIPTION, IS_PREVIEW } from '~/lib/constants' -import { post } from '~/lib/fetchWrapper' +import { API_URL, APP_NAME, DEFAULT_META_DESCRIPTION } from '~/lib/constants' import useDarkLaunchWeeks from '../hooks/useDarkLaunchWeeks' export default function App({ Component, pageProps }: AppProps) { const router = useRouter() - const telemetryProps = useTelemetryProps() const { hasAcceptedConsent } = useConsent() - const IS_DEV = !IS_PROD && !IS_PREVIEW - const blockEvents = IS_DEV || !hasAcceptedConsent - - const title = typeof document !== 'undefined' ? document?.title : '' - const referrer = typeof document !== 'undefined' ? document?.referrer : '' - - const { search, language, viewport_height, viewport_width } = telemetryProps useThemeSandbox() - useTelemetryCookie({ hasAcceptedConsent, title, referrer }) - - function handlePageTelemetry(url: string) { - return post( - `${API_URL}/telemetry/page`, - { - page_url: url, - page_title: title, - pathname: router.pathname, - ph: { - referrer, - language, - search, - viewport_height, - viewport_width, - user_agent: navigator.userAgent, - }, - }, - { headers: { Version: '2' }, credentials: 'include' } - ) - } - - useEffect(() => { - if (blockEvents) return - - function handleRouteChange() { - handlePageTelemetry(window.location.href) - } - - // Listen for page changes after a navigation or when the query changes - router.events.on('routeChangeComplete', handleRouteChange) - return () => { - router.events.off('routeChangeComplete', handleRouteChange) - } - }, [router.events, blockEvents]) - - useEffect(() => { - if (!router.isReady) return - if (blockEvents) return - handlePageTelemetry(window.location.href) - }, [router.isReady, blockEvents]) - - useEffect(() => { - const handleBeforeUnload = async () => { - if (!blockEvents) { - await post(`${API_URL}/telemetry/page-leave`, { - page_url: window.location.href, - page_title: title, - pathname: router.pathname, - }) - } - } - window.addEventListener('beforeunload', handleBeforeUnload) - return () => { - window.removeEventListener('beforeunload', handleBeforeUnload) - } - }, [blockEvents, router.pathname, title]) - const site_title = `${APP_NAME} | The Open Source Firebase Alternative` const { basePath } = useRouter() @@ -153,18 +84,21 @@ export default function App({ Component, pageProps }: AppProps) { /> - theme.value)} - enableSystem - disableTransitionOnChange - forcedTheme={forceDarkMode ? 'dark' : undefined} - > - - - - - - + + theme.value)} + enableSystem + disableTransitionOnChange + forcedTheme={forceDarkMode ? 'dark' : undefined} + > + + + + + + + + ) diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 47efd7a6e4b..7326b710f31 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -873,8 +873,8 @@ export interface paths { patch: operations['ContentController_updateContent'] } '/platform/projects/{ref}/content/count': { - /** Gets the count of a user's content */ - get: operations['ContentController_getContentCount'] + /** Gets the user's content counts */ + get: operations['ContentController_getContentCountV2'] } '/platform/projects/{ref}/content/folders': { /** Gets project's content root folder */ @@ -1144,7 +1144,7 @@ export interface paths { } '/platform/telemetry/page-leave': { /** Send analytics page leave event */ - post: operations['TelemetryPageLeaveController_pageLeave'] + post: operations['TelemetryPageLeaveController_trackPageLeave'] } '/platform/telemetry/reset': { /** Reset analytics */ @@ -1879,8 +1879,8 @@ export interface paths { patch: operations['ContentController_updateContent'] } '/v0/projects/{ref}/content/count': { - /** Gets the count of a user's content */ - get: operations['ContentController_getContentCount'] + /** Gets the user's content counts */ + get: operations['ContentController_getContentCountV2'] } '/v0/projects/{ref}/content/item/{id}': { /** Gets project's content by the given id */ @@ -2127,6 +2127,10 @@ export interface paths { /** Deletes the given project */ delete: operations['v1-delete-a-project'] } + '/v1/projects/{ref}/analytics/endpoints/logs.all': { + /** Gets project's logs */ + get: operations['V1ProjectLogsController_getLogs'] + } '/v1/projects/{ref}/api-keys': { /** Get project api keys */ get: operations['v1-get-project-api-keys'] @@ -2334,6 +2338,16 @@ export interface paths { /** Disables project's readonly mode for the next 15 minutes */ post: operations['v1-disable-readonly-mode-temporarily'] } + '/v1/projects/{ref}/restore': { + /** Lists available restore versions for the given project */ + get: operations['v1-list-available-restore-versions'] + /** Restores the given project */ + post: operations['v1-restore-a-project'] + } + '/v1/projects/{ref}/restore/cancel': { + /** Cancels the given project restoration */ + post: operations['v1-cancel-a-project-restoration'] + } '/v1/projects/{ref}/secrets': { /** * List all secrets @@ -2784,19 +2798,19 @@ export interface components { release_channel: string /** @enum {string} */ status: + | 'INACTIVE' | 'ACTIVE_HEALTHY' | 'ACTIVE_UNHEALTHY' | 'COMING_UP' + | 'UNKNOWN' | 'GOING_DOWN' - | 'INACTIVE' | 'INIT_FAILED' | 'REMOVED' - | 'RESTARTING' - | 'UNKNOWN' + | 'RESTORING' | 'UPGRADING' | 'PAUSING' - | 'RESTORING' | 'RESTORE_FAILED' + | 'RESTARTING' | 'PAUSE_FAILED' | 'RESIZING' } @@ -2840,6 +2854,19 @@ export interface components { result: components['schemas']['CustomHostnameDetails'] success: boolean } + CloneBackupsResponse: { + backups: components['schemas']['Backup'][] + physicalBackupData: { + earliestPhysicalBackupDateUnix?: number + latestPhysicalBackupDateUnix?: number + } + pitr_enabled: boolean + region: string + target_compute_size: string + target_volume_size_gb: number + tierKey: string + walg_enabled: boolean + } CloneProjectDto: { /** @default 0 */ cloneBackupId?: number @@ -3311,6 +3338,22 @@ export interface components { CustomSupabaseInternalRequests: { ami: components['schemas']['AmiSearchOptions'] } + /** @enum {string} */ + database_status: + | 'ACTIVE_HEALTHY' + | 'ACTIVE_UNHEALTHY' + | 'COMING_UP' + | 'GOING_DOWN' + | 'INIT_FAILED' + | 'REMOVED' + | 'RESTORING' + | 'UNKNOWN' + | 'INIT_READ_REPLICA' + | 'INIT_READ_REPLICA_FAILED' + | 'RESTARTING' + | 'RESIZING' + /** @enum {string} */ + database_type: 'PRIMARY' | 'READ_REPLICA' DatabaseDetailResponse: { /** @enum {string} */ cloud_provider: 'AWS' | 'FLY' @@ -3334,27 +3377,11 @@ export interface components { | 'REMOVED' | 'RESTORING' | 'UNKNOWN' - | 'UPGRADING' | 'INIT_READ_REPLICA' | 'INIT_READ_REPLICA_FAILED' | 'RESTARTING' | 'RESIZING' } - /** @enum {string} */ - DatabaseStatus: - | 'ACTIVE_HEALTHY' - | 'ACTIVE_UNHEALTHY' - | 'COMING_UP' - | 'GOING_DOWN' - | 'INIT_FAILED' - | 'REMOVED' - | 'RESTORING' - | 'UNKNOWN' - | 'UPGRADING' - | 'INIT_READ_REPLICA' - | 'INIT_READ_REPLICA_FAILED' - | 'RESTARTING' - | 'RESIZING' DatabaseStatusResponse: { identifier: string replicaInitializationStatus?: Record @@ -3368,14 +3395,11 @@ export interface components { | 'REMOVED' | 'RESTORING' | 'UNKNOWN' - | 'UPGRADING' | 'INIT_READ_REPLICA' | 'INIT_READ_REPLICA_FAILED' | 'RESTARTING' | 'RESIZING' } - /** @enum {string} */ - DatabaseType: 'PRIMARY' | 'READ_REPLICA' DatabaseUpgradeStatus: { /** @enum {string} */ error?: @@ -3661,6 +3685,11 @@ export interface components { GetContentCountResponse: { count: number } + GetContentCountV2Response: { + favorites: number + private: number + shared: number + } GetMetricsBody: { /** @enum {string} */ interval: '1d' | '3d' | '7d' @@ -3729,6 +3758,9 @@ export interface components { } updated_at: string } + GetProjectAvailableRestoreVersionsResponse: { + available_versions: components['schemas']['ProjectAvailableRestoreVersion'][] + } GetProjectByFlyExtensionIdResponse: { ref: string } @@ -4387,19 +4419,19 @@ export interface components { * @enum {string} */ status: + | 'INACTIVE' | 'ACTIVE_HEALTHY' | 'ACTIVE_UNHEALTHY' | 'COMING_UP' + | 'UNKNOWN' | 'GOING_DOWN' - | 'INACTIVE' | 'INIT_FAILED' | 'REMOVED' - | 'RESTARTING' - | 'UNKNOWN' + | 'RESTORING' | 'UPGRADING' | 'PAUSING' - | 'RESTORING' | 'RESTORE_FAILED' + | 'RESTARTING' | 'PAUSE_FAILED' | 'RESIZING' /** @@ -4483,7 +4515,7 @@ export interface components { } OrganizationSlugResponse: { billing_email: string | null - billing_metadata?: Record + billing_metadata: unknown has_oriole_project: boolean id: number name: string @@ -4900,6 +4932,23 @@ export interface components { username: string } /** @enum {string} */ + project_status: + | 'INACTIVE' + | 'ACTIVE_HEALTHY' + | 'ACTIVE_UNHEALTHY' + | 'COMING_UP' + | 'UNKNOWN' + | 'GOING_DOWN' + | 'INIT_FAILED' + | 'REMOVED' + | 'RESTORING' + | 'UPGRADING' + | 'PAUSING' + | 'RESTORE_FAILED' + | 'RESTARTING' + | 'PAUSE_FAILED' + | 'RESIZING' + /** @enum {string} */ ProjectAddonPricingInterval: 'monthly' | 'hourly' ProjectAddonsResponse: { available_addons: components['schemas']['AvailableAddonResponse'][] @@ -4936,8 +4985,17 @@ export interface components { db_schema: string endpoint: string } + ProjectAvailableRestoreVersion: { + /** @enum {string} */ + postgres_engine: '13' | '14' | '15' | '17-oriole' + /** @enum {string} */ + release_channel: 'internal' | 'alpha' | 'beta' | 'ga' | 'withdrawn' | 'preview' + version: string + } ProjectClonedResponse: { source_project_ref: string + target_disk_size_gb: number + target_instance_size: string target_project_ref: string } ProjectClonedStatusResponse: { @@ -4961,8 +5019,8 @@ export interface components { identifier: string infra_compute_size?: components['schemas']['DbInstanceSize'] region: string - status: components['schemas']['DatabaseStatus'] - type: components['schemas']['DatabaseType'] + status: components['schemas']['database_status'] + type: components['schemas']['database_type'] } ProjectDetailResponse: { cloud_provider: string @@ -4984,19 +5042,19 @@ export interface components { restUrl: string /** @enum {string} */ status: + | 'INACTIVE' | 'ACTIVE_HEALTHY' | 'ACTIVE_UNHEALTHY' | 'COMING_UP' + | 'UNKNOWN' | 'GOING_DOWN' - | 'INACTIVE' | 'INIT_FAILED' | 'REMOVED' - | 'RESTARTING' - | 'UNKNOWN' + | 'RESTORING' | 'UPGRADING' | 'PAUSING' - | 'RESTORING' | 'RESTORE_FAILED' + | 'RESTARTING' | 'PAUSE_FAILED' | 'RESIZING' subscription_id: string @@ -5150,26 +5208,11 @@ export interface components { ssl_enforced: boolean status: string } - /** @enum {string} */ - ProjectStatus: - | 'ACTIVE_HEALTHY' - | 'ACTIVE_UNHEALTHY' - | 'COMING_UP' - | 'GOING_DOWN' - | 'INACTIVE' - | 'INIT_FAILED' - | 'REMOVED' - | 'RESTARTING' - | 'UNKNOWN' - | 'UPGRADING' - | 'PAUSING' - | 'RESTORING' - | 'RESTORE_FAILED' - | 'PAUSE_FAILED' - | 'RESIZING' ProjectUnpauseVersionInfo: { - postgres_engine: components['schemas']['PostgresEngine'] - release_channel: components['schemas']['ReleaseChannel'] + /** @enum {string} */ + postgres_engine: '13' | '14' | '15' | '17-oriole' + /** @enum {string} */ + release_channel: 'internal' | 'alpha' | 'beta' | 'ga' | 'withdrawn' | 'preview' version: string } ProjectUpgradeEligibilityResponse: { @@ -5197,7 +5240,7 @@ export interface components { name: string ref: string region: string - status: components['schemas']['ProjectStatus'] + status: components['schemas']['project_status'] } Provider: { created_at?: string @@ -5263,7 +5306,6 @@ export interface components { | 'REMOVED' | 'RESTORING' | 'UNKNOWN' - | 'UPGRADING' | 'INIT_READ_REPLICA' | 'INIT_READ_REPLICA_FAILED' | 'RESTARTING' @@ -5337,19 +5379,19 @@ export interface components { * @enum {string} */ status: + | 'INACTIVE' | 'ACTIVE_HEALTHY' | 'ACTIVE_UNHEALTHY' | 'COMING_UP' + | 'UNKNOWN' | 'GOING_DOWN' - | 'INACTIVE' | 'INIT_FAILED' | 'REMOVED' - | 'RESTARTING' - | 'UNKNOWN' + | 'RESTORING' | 'UPGRADING' | 'PAUSING' - | 'RESTORING' | 'RESTORE_FAILED' + | 'RESTARTING' | 'PAUSE_FAILED' | 'RESIZING' /** @@ -5393,6 +5435,18 @@ export interface components { id: number recovery_time_target: string } + RestoreProjectBodyDto: { + /** + * @description Postgres engine version. If not provided, the latest version from the given release channel will be used. + * @enum {string} + */ + postgres_engine?: '15' | '17-oriole' + /** + * @description Release channel version. If not provided, GeneralAvailability will be used. + * @enum {string} + */ + release_channel?: 'internal' | 'alpha' | 'beta' | 'ga' | 'withdrawn' | 'preview' + } RestrictionData: { grace_period_end?: string report_date?: string @@ -5631,7 +5685,7 @@ export interface components { data: components['schemas']['SnippetMeta'][] } SnippetMeta: { - description?: string + description: string | null id: string inserted_at: string name: string @@ -5651,7 +5705,7 @@ export interface components { } SnippetResponse: { content: components['schemas']['SnippetContent'] - description?: string + description: string | null id: string inserted_at: string name: string @@ -5855,21 +5909,22 @@ export interface components { TelemetryCallFeatureFlagsResponseDto: { [key: string]: unknown } - TelemetryEventBodyV2: { + TelemetryEventBodyV2Dto: { action: string - custom_properties: Record + custom_properties: { + [key: string]: unknown + } page_title: string page_url: string pathname: string - ph: components['schemas']['TelemetryEventPostHog'] - } - TelemetryEventPostHog: { - language: string - referrer: string - search: string - user_agent: string - viewport_height: number - viewport_width: number + ph: { + language: string + referrer: string + search: string + user_agent: string + viewport_height: number + viewport_width: number + } } TelemetryFeatureFlagBodyDto: { feature_flag_name: string @@ -5888,25 +5943,30 @@ export interface components { project_ref?: string user_id: string } - TelemetryPageBodyV2: { + TelemetryPageBodyV2Dto: { + feature_flags?: { + [key: string]: unknown + } page_title: string page_url: string pathname: string - ph: components['schemas']['TelemetryPagePostHog'] + ph: { + language: string + referrer: string + search: string + user_agent: string + viewport_height: number + viewport_width: number + } } - TelemetryPageLeaveBody: { + TelemetryPageLeaveBodyDto: { + feature_flags?: { + [key: string]: unknown + } page_title: string page_url: string pathname: string } - TelemetryPagePostHog: { - language: string - referrer: string - search: string - user_agent: string - viewport_height: number - viewport_width: number - } ThirdPartyAuth: { custom_jwks?: unknown id: string @@ -5932,11 +5992,19 @@ export interface components { UnpauseProjectAvailableVersionsResponse: { available_versions: components['schemas']['ProjectUnpauseVersionInfo'][] } - UnpauseProjectBody: { - postgres_engine?: components['schemas']['PostgresEngine'] - release_channel?: components['schemas']['ReleaseChannel'] + UnpauseProjectBodyDto: { + /** + * @description Postgres engine version. If not provided, the latest version from the given release channel will be used. + * @enum {string} + */ + postgres_engine?: '15' | '17-oriole' + /** + * @description Release channel version. If not provided, GeneralAvailability will be used. + * @enum {string} + */ + release_channel?: 'internal' | 'alpha' | 'beta' | 'ga' | 'withdrawn' | 'preview' } - UnpauseProjectInfo: { + UnpauseProjectResponse: { back_ups: components['schemas']['BackupId'][] cloud_provider: string id: number @@ -6710,7 +6778,8 @@ export interface components { updated_at?: string } UpdateVercelConnectionsBody: { - env_sync_targets?: ('production' | 'preview' | 'development')[] + /** @enum {array} */ + env_sync_targets?: production | preview | development public_env_var_prefix?: string } UpgradeDatabaseBody: { @@ -6822,6 +6891,26 @@ export interface components { total: number users: components['schemas']['UserBody'][] } + V1AnalyticsResponse: { + error?: OneOf< + [ + { + code?: number + errors?: { + domain?: string + location?: string + locationType?: string + message?: string + reason?: string + }[] + message?: string + status?: string + }, + string, + ] + > + result?: Record[] + } V1Backup: { inserted_at: string is_physical_backup: boolean @@ -6982,19 +7071,19 @@ export interface components { region: string /** @enum {string} */ status: + | 'INACTIVE' | 'ACTIVE_HEALTHY' | 'ACTIVE_UNHEALTHY' | 'COMING_UP' + | 'UNKNOWN' | 'GOING_DOWN' - | 'INACTIVE' | 'INIT_FAILED' | 'REMOVED' - | 'RESTARTING' - | 'UNKNOWN' + | 'RESTORING' | 'UPGRADING' | 'PAUSING' - | 'RESTORING' | 'RESTORE_FAILED' + | 'RESTARTING' | 'PAUSE_FAILED' | 'RESIZING' } @@ -7018,19 +7107,19 @@ export interface components { region: string /** @enum {string} */ status: + | 'INACTIVE' | 'ACTIVE_HEALTHY' | 'ACTIVE_UNHEALTHY' | 'COMING_UP' + | 'UNKNOWN' | 'GOING_DOWN' - | 'INACTIVE' | 'INIT_FAILED' | 'REMOVED' - | 'RESTARTING' - | 'UNKNOWN' + | 'RESTORING' | 'UPGRADING' | 'PAUSING' - | 'RESTORING' | 'RESTORE_FAILED' + | 'RESTARTING' | 'PAUSE_FAILED' | 'RESIZING' } @@ -8019,7 +8108,7 @@ export interface operations { responses: { 200: { content: { - 'application/json': components['schemas']['BackupsResponse'] + 'application/json': components['schemas']['CloneBackupsResponse'] } } /** @description Failed to list available valid backups */ @@ -11850,16 +11939,12 @@ export interface operations { /** Gets project's logs */ LogsController_getApiPaths: { parameters: { - query: { - sql: string - project: string - iso_timestamp_start: string - iso_timestamp_end: string - timestamp_start: string - timestamp_end: string + query?: { + iso_timestamp_end?: string + iso_timestamp_start?: string + sql?: string } path: { - /** @description Project ref */ ref: string } } @@ -12874,13 +12959,11 @@ export interface operations { } } } - /** Gets the count of a user's content */ - ContentController_getContentCount: { + /** Gets the user's content counts */ + ContentController_getContentCountV2: { parameters: { query?: { - visibility?: string type?: 'sql' | 'report' | 'log_sql' - favorite?: boolean name?: string } path: { @@ -12890,10 +12973,10 @@ export interface operations { responses: { 200: { content: { - 'application/json': components['schemas']['GetContentCountResponse'] + 'application/json': components['schemas']['GetContentCountV2Response'] } } - /** @description Failed to retrieve user's content count */ + /** @description Failed to retrieve user's content counts */ 500: { content: never } @@ -13479,19 +13562,18 @@ export interface operations { UnpauseController_unpauseProject: { parameters: { path: { - /** @description Project ref */ ref: string } } requestBody: { content: { - 'application/json': components['schemas']['UnpauseProjectBody'] + 'application/json': components['schemas']['UnpauseProjectBodyDto'] } } responses: { - 201: { + 200: { content: { - 'application/json': components['schemas']['UnpauseProjectInfo'] + 'application/json': components['schemas']['UnpauseProjectResponse'] } } 403: { @@ -13507,12 +13589,11 @@ export interface operations { UnpauseController_cancelProjectRestoration: { parameters: { path: { - /** @description Project ref */ ref: string } } responses: { - 201: { + 200: { content: { 'application/json': components['schemas']['RestoreCancellation'] } @@ -13530,7 +13611,6 @@ export interface operations { UnpauseController_getAvailableImageVersions: { parameters: { path: { - /** @description Project ref */ ref: string } } @@ -14374,7 +14454,7 @@ export interface operations { TelemetryEventController_sendServerEventV2: { requestBody: { content: { - 'application/json': components['schemas']['TelemetryEventBodyV2'] + 'application/json': components['schemas']['TelemetryEventBodyV2Dto'] } } responses: { @@ -14474,7 +14554,7 @@ export interface operations { TelemetryPageController_sendServerPageV2: { requestBody: { content: { - 'application/json': components['schemas']['TelemetryPageBodyV2'] + 'application/json': components['schemas']['TelemetryPageBodyV2Dto'] } } responses: { @@ -14488,13 +14568,17 @@ export interface operations { } } /** Send analytics page leave event */ - TelemetryPageLeaveController_pageLeave: { + TelemetryPageLeaveController_trackPageLeave: { requestBody: { content: { - 'application/json': components['schemas']['TelemetryPageLeaveBody'] + 'application/json': components['schemas']['TelemetryPageLeaveBodyDto'] } } responses: { + /** @description Page leave event sent */ + 200: { + content: never + } 201: { content: never } @@ -16079,6 +16163,29 @@ export interface operations { } } } + /** Gets project's logs */ + V1ProjectLogsController_getLogs: { + parameters: { + query?: { + iso_timestamp_end?: string + iso_timestamp_start?: string + sql?: string + } + path: { + ref: string + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['V1AnalyticsResponse'] + } + } + 403: { + content: never + } + } + } /** Get project api keys */ 'v1-get-project-api-keys': { parameters: { @@ -17189,6 +17296,61 @@ export interface operations { } } } + /** Lists available restore versions for the given project */ + 'v1-list-available-restore-versions': { + parameters: { + path: { + ref: string + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['GetProjectAvailableRestoreVersionsResponse'] + } + } + 403: { + content: never + } + } + } + /** Restores the given project */ + 'v1-restore-a-project': { + parameters: { + path: { + ref: string + } + } + requestBody: { + content: { + 'application/json': components['schemas']['RestoreProjectBodyDto'] + } + } + responses: { + 200: { + content: never + } + 403: { + content: never + } + } + } + /** Cancels the given project restoration */ + 'v1-cancel-a-project-restoration': { + parameters: { + path: { + ref: string + } + } + responses: { + 200: { + content: never + } + 403: { + content: never + } + } + } /** [Beta] Get project's SSL enforcement configuration. */ 'v1-get-ssl-enforcement-config': { parameters: { diff --git a/packages/common/feature-flags.tsx b/packages/common/feature-flags.tsx new file mode 100644 index 00000000000..a715439a88b --- /dev/null +++ b/packages/common/feature-flags.tsx @@ -0,0 +1,138 @@ +import { FlagValues } from '@vercel/flags/react' +import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' + +import { components } from 'api-types' +import { useUser } from './auth' +import { LOCAL_STORAGE_KEYS } from './constants' +import { get, post } from './fetchWrappers' +import { ensurePlatformSuffix } from './helpers' + +type TrackFeatureFlagVariables = components['schemas']['TelemetryFeatureFlagBodyDto'] +export type CallFeatureFlagsResponse = components['schemas']['TelemetryCallFeatureFlagsResponseDto'] + +export async function getFeatureFlags(API_URL: string) { + const data = await get(`${ensurePlatformSuffix(API_URL)}/telemetry/feature-flags`) + + return data as CallFeatureFlagsResponse +} + +export async function trackFeatureFlag(API_URL: string, body: TrackFeatureFlagVariables) { + const consent = + (typeof window !== 'undefined' + ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) + : null) === 'true' + + if (!consent) return undefined + await post(`${ensurePlatformSuffix(API_URL)}/telemetry/feature-flags/track`, { body }) +} + +export type FeatureFlagContextType = { + API_URL?: string + configcat: { [key: string]: boolean | number | string | null } + posthog: CallFeatureFlagsResponse + hasLoaded?: boolean +} + +const FeatureFlagContext = createContext({ + API_URL: undefined, + configcat: {}, + posthog: {}, + hasLoaded: false, +}) + +function getCookies() { + const pairs = document.cookie.split(';') + let cookies: Record = {} + for (var i = 0; i < pairs.length; i++) { + var [t_key, value] = pairs[i].split('=') + const key = t_key.trim() + + cookies[key] = unescape(value) + } + return cookies +} + +export const FeatureFlagProvider = ({ + API_URL, + enabled = true, + getConfigCatFlags, + children, +}: PropsWithChildren<{ + API_URL: string + enabled?: boolean + getConfigCatFlags?: ( + userEmail?: string + ) => Promise<{ settingKey: string; settingValue: boolean | number | string | null | undefined }[]> +}>) => { + const user = useUser() + + const [store, setStore] = useState({ + API_URL, + configcat: {}, + posthog: {}, + hasLoaded: false, + }) + + useEffect(() => { + let mounted = true + + async function processFlags() { + if (!enabled) return + + let flagStore: FeatureFlagContextType = { configcat: {}, posthog: {} } + + // Load PH flags + const flags = await getFeatureFlags(API_URL) + if (flags) { + flagStore.posthog = flags + } + + // Load ConfigCat flags + if (typeof getConfigCatFlags === 'function') { + const flagValues = await getConfigCatFlags(user?.email) + let overridesCookieValue: Record = {} + try { + const cookies = getCookies() + overridesCookieValue = JSON.parse(cookies['vercel-flag-overrides']) + } catch {} + + flagValues.forEach((item) => { + flagStore['configcat'][item.settingKey] = + overridesCookieValue[item.settingKey] ?? + (item.settingValue === null ? null : item.settingValue ?? false) + }) + } + + flagStore.hasLoaded = true + + if (mounted) { + setStore(flagStore) + } + } + + // [Joshen] getFlags get triggered everytime the tab refocuses but this should be okay + // as per https://configcat.com/docs/sdk-reference/js/#polling-modes: + // The polling downloads the config.json at the set interval and are stored in the internal cache + // which subsequently all getValueAsync() calls are served from there + processFlags() + + return () => { + mounted = false + } + }, [enabled, user?.email]) + + return ( + + {/* + [Joshen] Just support configcat flags in Vercel flags for now for simplicity + although I think it should be fairly simply to support PH too + */} + + {children} + + ) +} + +export const useFeatureFlags = () => { + return useContext(FeatureFlagContext) +} diff --git a/packages/common/fetchWrappers.tsx b/packages/common/fetchWrappers.tsx index 4afdf9a9acc..3a02252edd7 100644 --- a/packages/common/fetchWrappers.tsx +++ b/packages/common/fetchWrappers.tsx @@ -1,13 +1,43 @@ +import { getAccessToken } from './auth' + interface DataProps { [prop: string]: any } -export const post = (url: string, data: DataProps, options = {} as { [key: string]: any }) => { +export async function get(url: string, options = {} as { [key: string]: any }) { const { headers: optionHeaders, ...otherOptions } = options + const accessToken = await getAccessToken() + let headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json', + ...(accessToken && { Authorization: `Bearer ${accessToken}` }), + ...optionHeaders, + }) + + return fetch(url, { + method: 'GET', + headers, + credentials: 'include', + referrerPolicy: 'no-referrer-when-downgrade', + ...otherOptions, + }) + .then((res) => res.json()) + .catch((error) => { + throw error + }) +} + +export async function post(url: string, data: DataProps, options = {} as { [key: string]: any }) { + const { headers: optionHeaders, ...otherOptions } = options + + const accessToken = await getAccessToken() + + let headers = new Headers({ + 'Content-Type': 'application/json', + Accept: 'application/json', + ...(accessToken && { Authorization: `Bearer ${accessToken}` }), ...optionHeaders, }) @@ -19,6 +49,6 @@ export const post = (url: string, data: DataProps, options = {} as { [key: strin body: JSON.stringify(data), ...otherOptions, }).catch((error) => { - console.error('Error at fetchWrapper - post:', error) + throw error }) } diff --git a/packages/common/helpers.ts b/packages/common/helpers.ts index 981ed7c3acf..4511d81f718 100644 --- a/packages/common/helpers.ts +++ b/packages/common/helpers.ts @@ -33,3 +33,7 @@ export const useReducedMotion = (): boolean => { () => false ) } + +export function ensurePlatformSuffix(apiUrl: string) { + return apiUrl.endsWith('/platform') ? apiUrl : `${apiUrl}/platform` +} diff --git a/packages/common/hooks/useTelemetryProps.ts b/packages/common/hooks/useTelemetryProps.ts index d70b3d76ac0..6218986da72 100644 --- a/packages/common/hooks/useTelemetryProps.ts +++ b/packages/common/hooks/useTelemetryProps.ts @@ -22,7 +22,7 @@ export function useTelemetryProps(): TelemetryProps { return { screenResolution: isBrowser ? `${window.innerWidth}x${window.innerHeight}` : undefined, page_url: isBrowser ? window.location.href : '', - pathname: router?.pathname ?? pathname ?? window.location.pathname, + pathname: router?.pathname ?? pathname ?? (isBrowser ? window.location.pathname : ''), search: isBrowser ? window.location.search : '', viewport_height: isBrowser ? window.innerHeight : 0, viewport_width: isBrowser ? window.innerWidth : 0, diff --git a/packages/common/index.tsx b/packages/common/index.tsx index a74063ca7a5..25a77e6adb4 100644 --- a/packages/common/index.tsx +++ b/packages/common/index.tsx @@ -9,3 +9,4 @@ export * from './hooks' export * from './MetaFavicons/pages-router' export * from './Providers' export * from './telemetry' +export * from './feature-flags' diff --git a/packages/common/package.json b/packages/common/package.json index 9b6a54a54f8..a75ccd196f4 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -12,6 +12,8 @@ }, "dependencies": { "@types/dat.gui": "^0.7.12", + "@vercel/flags": "^2.6.0", + "api-types": "workspace:*", "config": "workspace:*", "dat.gui": "^0.7.9", "lodash": "^4.17.21", diff --git a/packages/common/telemetry.ts b/packages/common/telemetry.ts deleted file mode 100644 index 5bd26e90f9d..00000000000 --- a/packages/common/telemetry.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { post } from './fetchWrappers' - -interface TelemetryData { - page_url: string - page_title: string - pathname: string - ph: { - referrer: string - language: string - search: string - viewport_height: number - viewport_width: number - user_agent: string - } -} - -export function handlePageTelemetry(API_URL: string, route: string, telemetryData: TelemetryData) { - const { page_url } = telemetryData - - const { search, language, viewport_height, viewport_width, referrer, user_agent } = - telemetryData.ph - - return post( - `${API_URL}/telemetry/page`, - { - page_url, - page_title: document.title, - pathname: route, - ph: { - referrer: referrer ?? document.referrer, - language, - search, - viewport_height, - viewport_width, - user_agent: user_agent ?? navigator.userAgent, - }, - }, - { headers: { Version: '2' }, credentials: 'include' } - ) -} - -export function handleResetTelemetry(API_URL: string) { - return post(`${API_URL}/telemetry/reset`, {}) -} diff --git a/packages/common/telemetry.tsx b/packages/common/telemetry.tsx new file mode 100644 index 00000000000..78b6ccc7964 --- /dev/null +++ b/packages/common/telemetry.tsx @@ -0,0 +1,156 @@ +import { components } from 'api-types' +import { useRouter } from 'next/compat/router' +import { usePathname } from 'next/navigation' +import { useCallback, useEffect, useRef } from 'react' +import { useFeatureFlags } from './feature-flags' +import { post } from './fetchWrappers' +import { ensurePlatformSuffix, isBrowser } from './helpers' +import { useTelemetryCookie } from './hooks' + +export function handlePageTelemetry( + API_URL: string, + pathname?: string, + featureFlags?: { + [key: string]: unknown + }, + telemetryDataOverride?: components['schemas']['TelemetryPageBodyV2Dto'] +) { + return post( + `${ensurePlatformSuffix(API_URL)}/telemetry/page`, + telemetryDataOverride !== undefined + ? { feature_flags: featureFlags, ...telemetryDataOverride } + : { + page_url: isBrowser ? window.location.href : '', + page_title: isBrowser ? document?.title : '', + pathname: pathname ? pathname : isBrowser ? window.location.pathname : '', + ph: { + referrer: isBrowser ? document?.referrer : '', + language: navigator.language ?? 'en-US', + user_agent: navigator.userAgent, + search: isBrowser ? window.location.search : '', + viewport_height: isBrowser ? window.innerHeight : 0, + viewport_width: isBrowser ? window.innerWidth : 0, + }, + feature_flags: featureFlags, + }, + { headers: { Version: '2' } } + ) +} + +export function handlePageLeaveTelemetry( + API_URL: string, + pathname: string, + featureFlags?: { + [key: string]: unknown + } +) { + return post(`${ensurePlatformSuffix(API_URL)}/telemetry/page-leave`, { + body: { + pathname, + page_url: isBrowser ? window.location.href : '', + page_title: isBrowser ? document?.title : '', + feature_flags: featureFlags, + }, + }) +} + +export function handleResetTelemetry(API_URL: string) { + return post(`${API_URL}/telemetry/reset`, {}) +} + +export const PageTelemetry = ({ + API_URL, + hasAcceptedConsent, + enabled = true, +}: { + API_URL: string + hasAcceptedConsent: boolean + enabled?: boolean +}) => { + const router = useRouter() + + const pagesPathname = router?.pathname + const appPathname = usePathname() + + const featureFlags = useFeatureFlags() + + const title = typeof document !== 'undefined' ? document?.title : '' + const referrer = typeof document !== 'undefined' ? document?.referrer : '' + useTelemetryCookie({ hasAcceptedConsent, title, referrer }) + + const sendPageTelemetry = useCallback(() => { + if (!(enabled && hasAcceptedConsent)) return Promise.resolve() + + return handlePageTelemetry( + API_URL, + pagesPathname ?? appPathname ?? undefined, + featureFlags.posthog + ).catch((e) => { + console.error('Problem sending telemetry page:', e) + }) + }, [API_URL, pagesPathname, appPathname, hasAcceptedConsent, featureFlags.posthog]) + + const sendPageLeaveTelemetry = useCallback(() => { + if (!(enabled && hasAcceptedConsent)) return Promise.resolve() + + return handlePageTelemetry( + API_URL, + pagesPathname ?? appPathname ?? undefined, + featureFlags.posthog + ).catch((e) => { + console.error('Problem sending telemetry page-leave:', e) + }) + }, [pagesPathname, appPathname, hasAcceptedConsent, featureFlags.posthog]) + + const hasSentInitialPageTelemetryRef = useRef(false) + + useEffect(() => { + // Send page telemetry on first page load + // Waiting for router ready before sending page_view + // if not the path will be dynamic route instead of the browser url + if ( + (router?.isReady ?? true) && + featureFlags.hasLoaded && + !hasSentInitialPageTelemetryRef.current + ) { + sendPageTelemetry() + hasSentInitialPageTelemetryRef.current = true + } + }, [router?.isReady, featureFlags.hasLoaded]) + + useEffect(() => { + // For pages router + if (router === null) return + + function handleRouteChange() { + sendPageTelemetry() + } + + // Listen for page changes after a navigation or when the query changes + router.events.on('routeChangeComplete', handleRouteChange) + + return () => { + router.events.off('routeChangeComplete', handleRouteChange) + } + }, [router]) + + useEffect(() => { + // For app router + if (router !== null) return + + if (appPathname) { + sendPageTelemetry() + } + }, [appPathname, router, sendPageTelemetry]) + + useEffect(() => { + if (!enabled) return + + const handleBeforeUnload = () => sendPageLeaveTelemetry() + + window.addEventListener('beforeunload', handleBeforeUnload) + return () => window.removeEventListener('beforeunload', handleBeforeUnload) + }, [enabled, sendPageLeaveTelemetry]) + + return null +} diff --git a/packages/ui-patterns/ConsentToast/index.tsx b/packages/ui-patterns/ConsentToast/index.tsx index ecaf811ba15..d407f95686d 100644 --- a/packages/ui-patterns/ConsentToast/index.tsx +++ b/packages/ui-patterns/ConsentToast/index.tsx @@ -5,13 +5,14 @@ import { handlePageTelemetry, isBrowser, useBreakpoint, + useFeatureFlags, useTelemetryProps, } from 'common' - import { noop } from 'lodash' import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { Button, cn } from 'ui' +import { proxy, useSnapshot } from 'valtio' import { PrivacySettings } from '../PrivacySettings' interface ConsentToastProps { @@ -77,13 +78,23 @@ export const ConsentToast = ({ onAccept = noop, onOptOut = noop }: ConsentToastP ) } +// [Alaister]: Using global state here so multiple components can access the consent value +const consentState = proxy({ + consentValue: (isBrowser ? localStorage?.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) : null) as + | string + | null, + setConsentValue: (value: string | null) => { + consentState.consentValue = value + }, +}) + export const useConsent = () => { const { TELEMETRY_CONSENT, TELEMETRY_DATA } = LOCAL_STORAGE_KEYS const consentToastId = useRef() const telemetryProps = useTelemetryProps() + const featureFlags = useFeatureFlags() - const initialValue = isBrowser ? localStorage?.getItem(TELEMETRY_CONSENT) : null - const [consentValue, setConsentValue] = useState(initialValue) + const { consentValue } = useSnapshot(consentState) const handleConsent = (value: 'true' | 'false') => { if (!isBrowser) return @@ -97,7 +108,8 @@ export const useConsent = () => { const telemetryData = JSON.parse(decodeURIComponent(encodedData)) handlePageTelemetry( process.env.NEXT_PUBLIC_API_URL!, - window.location.pathname, + telemetryProps.pathname, + featureFlags.posthog, telemetryData ) // remove the telemetry cookie @@ -106,24 +118,10 @@ export const useConsent = () => { console.error('Invalid telemetry data:', error) } } else { - const telemetryData = { - page_url: telemetryProps.page_url, - page_title: typeof document !== 'undefined' ? document?.title : '', - pathname: telemetryProps.pathname, - ph: { - referrer: typeof document !== 'undefined' ? document?.referrer : '', - language: telemetryProps.language, - search: telemetryProps.search, - viewport_height: telemetryProps.viewport_height, - viewport_width: telemetryProps.viewport_width, - user_agent: navigator.userAgent, - }, - } - handlePageTelemetry( process.env.NEXT_PUBLIC_API_URL!, - window.location.pathname, - telemetryData + telemetryProps.pathname, + featureFlags.posthog ) } } else { @@ -131,7 +129,7 @@ export const useConsent = () => { document.cookie = `${TELEMETRY_DATA}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/` } - setConsentValue(value) + consentState.setConsentValue(value) localStorage.setItem(TELEMETRY_CONSENT, value) if (consentToastId.current) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d73f05dd8ff..fd4ae6a9f58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1327,6 +1327,12 @@ importers: '@types/dat.gui': specifier: ^0.7.12 version: 0.7.12 + '@vercel/flags': + specifier: ^2.6.0 + version: 2.6.0(next@14.2.21(@babel/core@7.24.7(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.41.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + api-types: + specifier: workspace:* + version: link:../api-types config: specifier: workspace:* version: link:../config