chore: send feature flags for page events (#32590)

This commit is contained in:
Alaister Young authored and GitHub committed 2025-01-13 13:08:50 +08:00
1 parent 83c94206c2
commit 4577389177
29 files changed
+772 -698

No files matched your search

+18 -15
View File
@@ -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 (
<QueryClientProvider>
<AuthContainer>
<PageTelemetry />
<ScrollRestoration />
<ThemeProvider defaultTheme="system" enableSystem disableTransitionOnChange>
<CommandProvider>
<div className="flex flex-col">
<SiteLayout>
{children}
<DocsCommandMenu />
</SiteLayout>
<ThemeSandbox />
</div>
</CommandProvider>
<SonnerToaster position="top-right" />
</ThemeProvider>
<FeatureFlagProvider API_URL={API_URL}>
<PageTelemetry />
<ScrollRestoration />
<ThemeProvider defaultTheme="system" enableSystem disableTransitionOnChange>
<CommandProvider>
<div className="flex flex-col">
<SiteLayout>
{children}
<DocsCommandMenu />
</SiteLayout>
<ThemeSandbox />
</div>
</CommandProvider>
<SonnerToaster position="top-right" />
</ThemeProvider>
</FeatureFlagProvider>
</AuthContainer>
</QueryClientProvider>
)
@@ -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 (
<PageTelemetryImpl
API_URL={API_URL}
hasAcceptedConsent={hasAcceptedConsent}
enabled={IS_PLATFORM}
/>
)
}
export { PageTelemetry }
+1 -1
View File
@@ -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'
@@ -1,5 +0,0 @@
import { createContext } from 'react'
const FlagContext = createContext({ configcat: {}, posthog: {} })
export default FlagContext
@@ -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<string, string> = {}
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<FlagProviderStore>({ 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<string, boolean> = {}
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 (
<Provider value={store}>
{/*
[Joshen] Just support configcat flags in Vercel flags for now for simplicity
although I think it should be fairly simply to support PH too
*/}
<FlagValues values={store.configcat} />
{children}
</Provider>
)
}
export default FlagProvider
@@ -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
@@ -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,
})
@@ -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 =
@@ -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<ReturnType<typeof sendPageLeave>>
export const useSendPageLeaveMutation = ({
onSuccess,
onError,
...options
}: Omit<UseMutationOptions<SendPageLeaveData, ResponseError>, '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<SendPageLeaveData, ResponseError>((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,
})
}
@@ -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<ReturnType<typeof sendPage>>
export const useSendPageMutation = ({
onSuccess,
onError,
...options
}: Omit<UseMutationOptions<SendPageData, ResponseError, SendPageVariables>, 'mutationFn'> = {}) => {
const router = useRouter()
const title = typeof document !== 'undefined' ? document?.title : ''
const referrer = typeof document !== 'undefined' ? document?.referrer : ''
return useMutation<SendPageData, ResponseError, SendPageVariables>(
(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,
}
)
}
+4 -5
View File
@@ -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<T = boolean>(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<T = boolean>(name: string) {
return store[name] as T
}
// TODO(Alaister): move this to packages/common/feature-flags.tsx and rename to useFlag
export function usePHFlag<T = string | boolean>(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}`, '')
+4 -4
View File
@@ -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 []
}
+1 -1
View File
@@ -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
+47 -41
View File
@@ -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 ? <PageTelemetry>{props.children}</PageTelemetry> : <>{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) {
<QueryClientProvider client={queryClient}>
<Hydrate state={pageProps.dehydratedState}>
<AuthProvider>
<FlagProvider>
<FeatureFlagProvider
API_URL={API_URL}
enabled={IS_PLATFORM}
getConfigCatFlags={getConfigCatFlags}
>
<ProfileProvider>
<Head>
<title>Supabase</title>
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
</Head>
<MetaFaviconsPagesRouter applicationName="Supabase Studio" />
<TelemetryContainer>
<TooltipProvider>
<RouteValidationWrapper>
<ThemeProvider
defaultTheme="system"
themes={['dark', 'light', 'classic-dark']}
enableSystem
disableTransitionOnChange
>
<AppBannerContextProvider>
<CommandProvider>
<AppBannerWrapper>
<FeaturePreviewContextProvider>
{getLayout(<Component {...pageProps} />)}
<StudioCommandMenu />
<GenerateSql />
<FeaturePreviewModal />
</FeaturePreviewContextProvider>
</AppBannerWrapper>
<SonnerToaster position="top-right" />
</CommandProvider>
</AppBannerContextProvider>
</ThemeProvider>
</RouteValidationWrapper>
</TooltipProvider>
</TelemetryContainer>
<TooltipProvider>
<RouteValidationWrapper>
<ThemeProvider
defaultTheme="system"
themes={['dark', 'light', 'classic-dark']}
enableSystem
disableTransitionOnChange
>
<AppBannerContextProvider>
<CommandProvider>
<AppBannerWrapper>
<FeaturePreviewContextProvider>
{getLayout(<Component {...pageProps} />)}
<StudioCommandMenu />
<GenerateSql />
<FeaturePreviewModal />
</FeaturePreviewContextProvider>
</AppBannerWrapper>
<SonnerToaster position="top-right" />
</CommandProvider>
</AppBannerContextProvider>
</ThemeProvider>
</RouteValidationWrapper>
</TooltipProvider>
<PageTelemetry
API_URL={API_URL}
hasAcceptedConsent={hasAcceptedConsent}
enabled={IS_PLATFORM}
/>
<GroupsTelemetry hasAcceptedConsent={hasAcceptedConsent} />
{!isTestEnv && <HCaptchaLoadedStore />}
{!isTestEnv && <ReactQueryDevtools initialIsOpen={false} position="bottom-right" />}
</ProfileProvider>
</FlagProvider>
</FeatureFlagProvider>
</AuthProvider>
</Hydrate>
</QueryClientProvider>
@@ -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<ResponseData>) => {
return res.status(200).json({ count: 1 })
return res.status(200).json({
favorites: 0,
private: 0,
shared: 0,
})
}
@@ -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() },
+1 -1
View File
@@ -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
+18 -84
View File
@@ -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) {
/>
<AuthProvider>
<ThemeProvider
themes={themes.map((theme) => theme.value)}
enableSystem
disableTransitionOnChange
forcedTheme={forceDarkMode ? 'dark' : undefined}
>
<CommandProvider>
<SonnerToaster position="top-right" />
<Component {...pageProps} />
<WwwCommandMenu />
</CommandProvider>
</ThemeProvider>
<FeatureFlagProvider API_URL={API_URL}>
<ThemeProvider
themes={themes.map((theme) => theme.value)}
enableSystem
disableTransitionOnChange
forcedTheme={forceDarkMode ? 'dark' : undefined}
>
<CommandProvider>
<SonnerToaster position="top-right" />
<Component {...pageProps} />
<WwwCommandMenu />
<PageTelemetry API_URL={API_URL} hasAcceptedConsent={hasAcceptedConsent} />
</CommandProvider>
</ThemeProvider>
</FeatureFlagProvider>
</AuthProvider>
</>
)
+289 -127
View File
@@ -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<string, never>
@@ -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<string, never>
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<string, never>
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<string, never>[]
}
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: {
+138
View File
@@ -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<FeatureFlagContextType>({
API_URL: undefined,
configcat: {},
posthog: {},
hasLoaded: false,
})
function getCookies() {
const pairs = document.cookie.split(';')
let cookies: Record<string, string> = {}
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<FeatureFlagContextType>({
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<string, boolean> = {}
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 (
<FeatureFlagContext.Provider value={store}>
{/*
[Joshen] Just support configcat flags in Vercel flags for now for simplicity
although I think it should be fairly simply to support PH too
*/}
<FlagValues values={store.configcat} />
{children}
</FeatureFlagContext.Provider>
)
}
export const useFeatureFlags = () => {
return useContext(FeatureFlagContext)
}
+32 -2
View File
@@ -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
})
}
+4
View File
@@ -33,3 +33,7 @@ export const useReducedMotion = (): boolean => {
() => false
)
}
export function ensurePlatformSuffix(apiUrl: string) {
return apiUrl.endsWith('/platform') ? apiUrl : `${apiUrl}/platform`
}
+1 -1
View File
@@ -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,
+1
View File
@@ -9,3 +9,4 @@ export * from './hooks'
export * from './MetaFavicons/pages-router'
export * from './Providers'
export * from './telemetry'
export * from './feature-flags'
+2
View File
@@ -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",
-44
View File
@@ -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`, {})
}
+156
View File
@@ -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
}
+19 -21
View File
@@ -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<string | number>()
const telemetryProps = useTelemetryProps()
const featureFlags = useFeatureFlags()
const initialValue = isBrowser ? localStorage?.getItem(TELEMETRY_CONSENT) : null
const [consentValue, setConsentValue] = useState<string | null>(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) {
+6
View File
@@ -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