Files
supabase/packages/common/telemetry.tsx
T
Pamela Chia 01c178e159 chore(studio): graduate homeNew experiment (#43437)
## Summary

The `homeNew` PostHog experiment has concluded. This PR graduates it by
making the new homepage (`ProjectHome`, formerly `HomeV2`) the permanent
default for all users, and removes all dead code from the old
experiment.

## Changes

- Remove `homeNew` PostHog feature flag checks and `home_new` experiment
exposure tracking from 3 files
- Rename `HomeNew/` → `ProjectHome/` directory and `HomeV2` →
`ProjectHome` export
- Delete old `Home/Home.tsx` component (shared components like
`ProjectList/` are kept — still used by org pages)
- Delete `pages/project/[ref]/building.tsx` and add a server-side
redirect from `/project/:ref/building` → `/project/:ref` to prevent 404s
during rollout (old cached JS bundles may still route to `/building`)
- Simplify `ContentWrapper` building-state logic in `ProjectLayout` —
always redirect building projects to home, always suppress building
interstitial on home page
- Always route to `/project/{ref}` after project creation (remove
`/building` path)
- Update all Observability imports from `HomeNew` → `ProjectHome`

## Self-hosted behavior change

Self-hosted Studio previously showed the old `Home` component (client
libraries + example projects) since PostHog flags don't load. This PR
changes self-hosted to show `ProjectHome` (TopSection with service
status + instance diagram, advisor, custom reports). All sections query
backend APIs that exist on self-hosted. E2E tests pass against the
self-hosted build.

## Testing

- [x] `pnpm turbo run build --filter=studio` passes
- [x] No remaining references to `homeNew`, `home_new`, or `HomeNew` in
codebase
- [x] No broken imports to deleted files
- [x] Self-hosted E2E tests pass (145 passed, 1 flaky, 4 skipped)
- [x] `/building` redirect added to both platform and self-hosted config
blocks

**Quick test:**
1. Navigate to any project homepage — should render the ProjectHome
component
2. Create a new project — should redirect to `/project/{ref}` (not
`/building`)
3. Visit a project in `COMING_UP` state on a non-home route — should
redirect to home
4. Visit `/project/{ref}/building` directly — should 302 redirect to
`/project/{ref}`

## Linear

- fixes GROWTH-671
2026-03-10 17:03:58 +09:00

530 lines
16 KiB
TypeScript

'use client'
import { components } from 'api-types'
import { useRouter } from 'next/compat/router'
import { usePathname } from 'next/navigation'
import Script from 'next/script'
import { useCallback, useEffect, useRef } from 'react'
import { useLatest } from 'react-use'
import { useUser } from './auth'
import { hasConsented, useConsentState } from './consent-state'
import { IS_PLATFORM, IS_PROD, LOCAL_STORAGE_KEYS } from './constants'
import { useFeatureFlags } from './feature-flags'
import { post } from './fetchWrappers'
import type { FirstReferrerData, MwDiagData } from './first-referrer-cookie'
import {
isExternalReferrer,
parseFirstReferrerCookie,
parseMwDiagCookie,
} from './first-referrer-cookie'
import { ensurePlatformSuffix, isBrowser } from './helpers'
import { useParams, useTelemetryCookie } from './hooks'
import { posthogClient, type ClientTelemetryEvent } from './posthog-client'
import { TelemetryEvent } from './telemetry-constants'
import {
clearTelemetryDataCookie,
getSharedTelemetryData,
getTelemetryCookieOptions,
} from './telemetry-utils'
export { posthogClient, type ClientTelemetryEvent }
const { TELEMETRY_DATA } = LOCAL_STORAGE_KEYS
// Reexports GoogleTagManager with the right API key set
export const TelemetryTagManager = () => {
// useConsentState is used here to trigger a re-render when consent state changes
const { hasAccepted } = useConsentState()
const isGTMEnabled = Boolean(
IS_PLATFORM && process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID && hasAccepted
)
if (!isGTMEnabled) return null
return (
<Script
id="consent"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s);j.async=true;j.src="https://ss.supabase.com/4icgbaujh.js?"+i;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','60a389s=aWQ9R1RNLVdDVlJMTU43&page=2');`,
}}
/>
)
}
//---
// PAGE TELEMETRY
//---
type SharedTelemetryData = ReturnType<typeof getSharedTelemetryData>
function getFirstTouchAttributionProps(telemetryData: SharedTelemetryData) {
const urlString = telemetryData.page_url
try {
const url = new URL(urlString)
url.hash = ''
const params = url.searchParams
const getParam = (key: string) => {
const value = params.get(key)
return value && value.length > 0 ? value : undefined
}
const utmProps = {
...(getParam('utm_source') && { $utm_source: getParam('utm_source') }),
...(getParam('utm_medium') && { $utm_medium: getParam('utm_medium') }),
...(getParam('utm_campaign') && { $utm_campaign: getParam('utm_campaign') }),
...(getParam('utm_content') && { $utm_content: getParam('utm_content') }),
...(getParam('utm_term') && { $utm_term: getParam('utm_term') }),
}
// Ad network click IDs (first-touch attribution)
const clickIdProps = {
...(getParam('gclid') && { gclid: getParam('gclid') }), // Google Ads
...(getParam('gbraid') && { gbraid: getParam('gbraid') }), // Google Ads (iOS)
...(getParam('wbraid') && { wbraid: getParam('wbraid') }), // Google Ads (iOS)
...(getParam('msclkid') && { msclkid: getParam('msclkid') }), // Microsoft Ads (Bing)
...(getParam('fbclid') && { fbclid: getParam('fbclid') }), // Meta (Facebook/Instagram)
...(getParam('rdt_cid') && { rdt_cid: getParam('rdt_cid') }), // Reddit Ads
...(getParam('ttclid') && { ttclid: getParam('ttclid') }), // TikTok Ads
...(getParam('twclid') && { twclid: getParam('twclid') }), // X Ads (Twitter)
...(getParam('li_fat_id') && { li_fat_id: getParam('li_fat_id') }), // LinkedIn Ads
}
return {
...utmProps,
...clickIdProps,
first_touch_url: url.href,
first_touch_pathname: url.pathname,
...(url.search && { first_touch_search: url.search }),
}
} catch {
return {}
}
}
interface HandlePageTelemetryOptions {
apiUrl: string
pathname?: string
featureFlags?: Record<string, unknown>
slug?: string
ref?: string
telemetryDataOverride?: SharedTelemetryData
firstReferrerData?: FirstReferrerData | null
mwDiagData?: MwDiagData | null
}
function handlePageTelemetry({
apiUrl: API_URL,
pathname,
featureFlags,
slug,
ref,
telemetryDataOverride,
firstReferrerData,
mwDiagData,
}: HandlePageTelemetryOptions) {
// Send to PostHog client-side (only in browser)
if (typeof window !== 'undefined') {
const livePageData = getSharedTelemetryData(pathname)
const liveReferrer = livePageData.ph.referrer
const cookieReferrer = telemetryDataOverride?.ph?.referrer
const shouldUseCookieReferrer = Boolean(
cookieReferrer && isExternalReferrer(cookieReferrer) && !isExternalReferrer(liveReferrer)
)
const pageData = telemetryDataOverride
? {
...livePageData,
ph: {
...livePageData.ph,
referrer: shouldUseCookieReferrer ? cookieReferrer! : liveReferrer,
},
}
: { ...livePageData, ph: { ...livePageData.ph } }
const firstTouchAttributionProps: Record<string, string> = {
...(telemetryDataOverride ? getFirstTouchAttributionProps(telemetryDataOverride) : {}),
}
// --- First-referrer edge cookie handoff ---
// If the edge cookie has external context and the current referrer is internal,
// override the referrer so PostHog gets the real acquisition source.
const firstReferrerCookiePresent = Boolean(firstReferrerData)
let firstReferrerCookieConsumed = false
if (
firstReferrerData &&
isExternalReferrer(firstReferrerData.referrer) &&
!isExternalReferrer(pageData.ph.referrer)
) {
pageData.ph.referrer = firstReferrerData.referrer
firstReferrerCookieConsumed = true
// Prefer attribution context captured at the external entry point.
const { utms, click_ids, landing_url } = firstReferrerData
Object.entries(utms).forEach(([key, value]) => {
const phKey = key.startsWith('utm_') ? `$${key}` : key
firstTouchAttributionProps[phKey] = value
})
Object.entries(click_ids).forEach(([key, value]) => {
firstTouchAttributionProps[key] = value
})
try {
const url = new URL(landing_url)
firstTouchAttributionProps.first_touch_url = url.href
firstTouchAttributionProps.first_touch_pathname = url.pathname
if (url.search) {
firstTouchAttributionProps.first_touch_search = url.search
} else {
delete firstTouchAttributionProps.first_touch_search
}
} catch {
// Skip if landing URL is malformed
}
}
const $referrer = pageData.ph.referrer
const $referring_domain = (() => {
if (!$referrer) return undefined
try {
return new URL($referrer).hostname
} catch {
return undefined
}
})()
// Align frontend and backend session IDs for correlation
if (pageData.session_id) {
document.cookie = `session_id=${pageData.session_id}; ${getTelemetryCookieOptions()}`
}
posthogClient.capturePageView({
$current_url: pageData.page_url,
$pathname: pageData.pathname,
$host: new URL(pageData.page_url).hostname,
...($referrer && { $referrer }),
...($referring_domain && { $referring_domain }),
...firstTouchAttributionProps,
$groups: {
...(slug ? { organization: slug } : {}),
...(ref ? { project: ref } : {}),
},
page_title: pageData.page_title,
...(pageData.session_id && { $session_id: pageData.session_id }),
...pageData.ph,
...Object.fromEntries(
Object.entries(featureFlags || {}).map(([k, v]) => [`$feature/${k}`, v])
),
// Measurement properties for handoff observability
// Only included on the initial pageview (when firstReferrerData is explicitly
// passed as null or a value — subsequent pageviews leave it as undefined)
...(firstReferrerData !== undefined && {
first_referrer_cookie_present: firstReferrerCookiePresent,
first_referrer_cookie_consumed: firstReferrerCookieConsumed,
}),
// Phase 1 middleware diagnostic: present only when _sb_mw_diag cookie was found
// (i.e., the request was routed through www middleware on /dashboard or /docs)
...(mwDiagData && {
mw_diag_hit: mwDiagData.hit,
mw_diag_would_stamp: mwDiagData.would_stamp,
mw_diag_has_existing_cookie: mwDiagData.has_existing_cookie,
}),
})
}
return Promise.resolve()
}
export function handlePageLeaveTelemetry(
API_URL: string,
pathname: string,
featureFlags?: {
[key: string]: unknown
},
slug?: string,
ref?: string
) {
// Send to PostHog client-side (only in browser)
if (typeof window !== 'undefined') {
const pageData = getSharedTelemetryData(pathname)
posthogClient.capturePageLeave({
$current_url: pageData.page_url,
$pathname: pageData.pathname,
page_title: pageData.page_title,
...(pageData.session_id && { $session_id: pageData.session_id }),
})
}
return Promise.resolve()
}
export const PageTelemetry = ({
API_URL,
hasAcceptedConsent,
enabled = true,
organizationSlug,
projectRef,
}: {
API_URL: string
hasAcceptedConsent: boolean
enabled?: boolean
organizationSlug?: string
projectRef?: string
}) => {
const router = useRouter()
const pagesPathname = router?.pathname
const appPathname = usePathname()
// Get from props or try to extract from URL params
const params = useParams()
const slug = organizationSlug || params.slug
const ref = projectRef || params.ref
const featureFlags = useFeatureFlags()
useTelemetryCookie({ enabled: enabled && IS_PLATFORM })
const pathname =
pagesPathname ?? appPathname ?? (isBrowser ? window.location.pathname : undefined)
const pathnameRef = useLatest(pathname)
const featureFlagsRef = useLatest(featureFlags.posthog)
const sendPageTelemetry = useCallback(() => {
if (!(enabled && hasAcceptedConsent)) return Promise.resolve()
return handlePageTelemetry({
apiUrl: API_URL,
pathname: pathnameRef.current,
featureFlags: featureFlagsRef.current,
slug,
ref,
}).catch((e) => {
console.error('Problem sending telemetry page:', e)
})
}, [API_URL, enabled, hasAcceptedConsent, slug, ref])
const sendPageLeaveTelemetry = useCallback(() => {
if (!(enabled && hasAcceptedConsent)) return Promise.resolve()
if (!pathnameRef.current) return Promise.resolve()
return handlePageLeaveTelemetry(
API_URL,
pathnameRef.current,
featureFlagsRef.current,
slug,
ref
).catch((e) => {
console.error('Problem sending telemetry page-leave:', e)
})
}, [API_URL, enabled, hasAcceptedConsent, slug, ref])
// Handle initial page telemetry event
const hasSentInitialPageTelemetryRef = useRef(false)
// Track previous pathname for App Router to detect actual changes
const previousAppPathnameRef = useRef<string | null>(null)
// Initialize PostHog client when consent is accepted
useEffect(() => {
if (hasAcceptedConsent && IS_PLATFORM) {
posthogClient.init(true)
}
}, [hasAcceptedConsent, IS_PLATFORM])
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) &&
hasAcceptedConsent &&
!hasSentInitialPageTelemetryRef.current
) {
// Read the edge-set cookies (cross-app handoff)
const cookieHeader = document.cookie
const firstReferrerData = parseFirstReferrerCookie(cookieHeader)
const mwDiagData = parseMwDiagCookie(cookieHeader)
const cookies = cookieHeader.split(';')
const telemetryCookieValue = cookies
.map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith(`${TELEMETRY_DATA}=`))
?.slice(`${TELEMETRY_DATA}=`.length)
if (telemetryCookieValue) {
try {
const telemetryData = JSON.parse(
decodeURIComponent(telemetryCookieValue)
) as SharedTelemetryData
handlePageTelemetry({
apiUrl: API_URL,
pathname: pathnameRef.current,
featureFlags: featureFlagsRef.current,
slug,
ref,
telemetryDataOverride: telemetryData,
firstReferrerData,
mwDiagData,
})
} catch (error) {
if (!IS_PROD) {
console.warn('Invalid telemetry cookie data:', error)
}
handlePageTelemetry({
apiUrl: API_URL,
pathname: pathnameRef.current,
featureFlags: featureFlagsRef.current,
slug,
ref,
firstReferrerData,
mwDiagData,
})
} finally {
clearTelemetryDataCookie()
}
} else {
handlePageTelemetry({
apiUrl: API_URL,
pathname: pathnameRef.current,
featureFlags: featureFlagsRef.current,
slug,
ref,
firstReferrerData,
mwDiagData,
})
}
hasSentInitialPageTelemetryRef.current = true
}
}, [router?.isReady, hasAcceptedConsent, slug, ref])
useEffect(() => {
// For pages router
if (router === null) return
function handleRouteChange() {
// Wait until we've sent the initial page telemetry event
if (!hasSentInitialPageTelemetryRef.current) return
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
// Only track if pathname actually changed (not initial mount)
if (
appPathname &&
previousAppPathnameRef.current !== null &&
previousAppPathnameRef.current !== appPathname
) {
sendPageTelemetry()
}
// Update previous pathname
previousAppPathnameRef.current = appPathname
}, [appPathname, router, sendPageTelemetry])
useEffect(() => {
if (!enabled) return
const handleBeforeUnload = () => sendPageLeaveTelemetry()
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [enabled, sendPageLeaveTelemetry])
// Identify the user
useTelemetryIdentify(API_URL)
return null
}
// ---
// EVENT TELEMETRY
// ---
type EventBody = components['schemas']['TelemetryEventBodyV2']
export function sendTelemetryEvent(API_URL: string, event: TelemetryEvent, pathname?: string) {
const consent = hasConsented()
if (!consent) return
const body: EventBody = {
...getSharedTelemetryData(pathname),
action: event.action,
custom_properties: 'properties' in event ? event.properties : {},
groups: 'groups' in event ? { ...event.groups } : {},
}
if (body.groups?.project === 'Unknown') {
delete body.groups.project
if (body.groups?.organization === 'Unknown') {
delete body.groups
}
}
return post(`${ensurePlatformSuffix(API_URL)}/telemetry/event`, body, {
headers: { Version: '2' },
})
}
//---
// TELEMETRY IDENTIFY
//---
type IdentifyBody = components['schemas']['TelemetryIdentifyBodyV2']
export function sendTelemetryIdentify(API_URL: string, body: IdentifyBody) {
const consent = hasConsented()
if (!consent) return Promise.resolve()
return post(`${ensurePlatformSuffix(API_URL)}/telemetry/identify`, body, {
headers: { Version: '2' },
})
}
export function useTelemetryIdentify(API_URL: string) {
const user = useUser()
useEffect(() => {
if (user?.id) {
const anonymousId = posthogClient.getDistinctId()
sendTelemetryIdentify(API_URL, {
user_id: user.id,
...(anonymousId && { anonymous_id: anonymousId }),
})
posthogClient.identify(user.id, { gotrue_id: user.id })
}
}, [API_URL, user?.id])
}
//---
// TELEMETRY RESET
//---
export function handleResetTelemetry(API_URL: string) {
return post(`${API_URL}/telemetry/reset`, {})
}