diff --git a/apps/studio/csp.js b/apps/studio/csp.js index ce9cf6e580..69108c1e06 100644 --- a/apps/studio/csp.js +++ b/apps/studio/csp.js @@ -26,6 +26,11 @@ const SUPABASE_CONTENT_API_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL ? new URL(process.env.NEXT_PUBLIC_CONTENT_API_URL).origin : '' +const isDevOrStaging = + process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' || + process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' || + process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' + const SUPABASE_STAGING_PROJECTS_URL = 'https://*.supabase.red' const SUPABASE_STAGING_PROJECTS_URL_WS = 'wss://*.supabase.red' const SUPABASE_COM_URL = 'https://supabase.com' @@ -58,6 +63,7 @@ const SUPABASE_ASSETS_URL = process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' ? 'https://frontend-assets.supabase.green' : 'https://frontend-assets.supabase.com' +const POSTHOG_URL = isDevOrStaging ? 'https://ph.supabase.green' : 'https://ph.supabase.com' const USERCENTRICS_URLS = 'https://*.usercentrics.eu' const USERCENTRICS_APP_URL = 'https://app.usercentrics.eu' @@ -89,6 +95,7 @@ module.exports.getCSP = function getCSP() { USERCENTRICS_URLS, STAPE_URL, GOOGLE_MAPS_API_URL, + POSTHOG_URL, ] const SCRIPT_SRC_URLS = [ CLOUDFLARE_CDN_URL, @@ -96,6 +103,7 @@ module.exports.getCSP = function getCSP() { STRIPE_JS_URL, SUPABASE_ASSETS_URL, STAPE_URL, + POSTHOG_URL, ] const FRAME_SRC_URLS = [HCAPTCHA_ASSET_URL, STRIPE_JS_URL, STAPE_URL] const IMG_SRC_URLS = [ @@ -111,11 +119,6 @@ module.exports.getCSP = function getCSP() { const STYLE_SRC_URLS = [CLOUDFLARE_CDN_URL, SUPABASE_ASSETS_URL] const FONT_SRC_URLS = [CLOUDFLARE_CDN_URL, SUPABASE_ASSETS_URL] - const isDevOrStaging = - process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' || - process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' || - process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' - const defaultSrcDirective = [ `default-src 'self'`, ...DEFAULT_SRC_URLS, diff --git a/apps/studio/lib/constants/index.ts b/apps/studio/lib/constants/index.ts index 7113b142d0..79338ec013 100644 --- a/apps/studio/lib/constants/index.ts +++ b/apps/studio/lib/constants/index.ts @@ -37,6 +37,12 @@ export const GOTRUE_ERRORS = { export const STRIPE_PUBLIC_KEY = process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY || 'pk_test_XVwg5IZH3I9Gti98hZw6KRzd00v5858heG' +export const POSTHOG_URL = + process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' || + process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' + ? 'https://ph.supabase.green' + : 'https://ph.supabase.com' + export const USAGE_APPROACHING_THRESHOLD = 0.75 export const OPT_IN_TAGS = { diff --git a/apps/studio/lib/telemetry.tsx b/apps/studio/lib/telemetry.tsx index 84b40e143d..5c4f9932ae 100644 --- a/apps/studio/lib/telemetry.tsx +++ b/apps/studio/lib/telemetry.tsx @@ -1,7 +1,7 @@ import { PageTelemetry } from 'common' -import GroupsTelemetry from 'components/ui/GroupsTelemetry' import { API_URL, IS_PLATFORM } from 'lib/constants' import { useConsentToast } from 'ui-patterns/consent' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' export function Telemetry() { // Although this is "technically" breaking the rules of hooks @@ -9,14 +9,16 @@ export function Telemetry() { // eslint-disable-next-line react-hooks/rules-of-hooks const { hasAcceptedConsent } = IS_PLATFORM ? useConsentToast() : { hasAcceptedConsent: true } + // Get org from selected organization query because it's not + // always available in the URL params + const { data: organization } = useSelectedOrganizationQuery() + return ( - <> - - - + ) } diff --git a/packages/common/package.json b/packages/common/package.json index 3ec29888ca..05d03bc9f5 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -13,12 +13,13 @@ "dependencies": { "@types/dat.gui": "^0.7.12", "@usercentrics/cmp-browser-sdk": "^4.42.0", - "flags": "^4.0.0", "api-types": "workspace:*", "config": "workspace:*", "dat.gui": "^0.7.9", + "flags": "^4.0.0", "lodash": "^4.17.21", "next-themes": "^0.3.0", + "posthog-js": "^1.257.2", "react-use": "^17.4.0", "valtio": "catalog:" }, diff --git a/packages/common/posthog-client.ts b/packages/common/posthog-client.ts new file mode 100644 index 0000000000..de95e29ab7 --- /dev/null +++ b/packages/common/posthog-client.ts @@ -0,0 +1,65 @@ +import posthog from 'posthog-js' +import { PostHogConfig } from 'posthog-js' + +interface PostHogClientConfig { + apiKey?: string + apiHost?: string +} + +class PostHogClient { + private initialized = false + private pendingGroups: Record = {} + private config: PostHogClientConfig + + constructor(config: PostHogClientConfig = {}) { + this.config = { + apiKey: config.apiKey || process.env.NEXT_PUBLIC_POSTHOG_KEY, + apiHost: + config.apiHost || process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://ph.supabase.green', + } + } + + init(hasConsent: boolean = true) { + if (this.initialized || typeof window === 'undefined' || !hasConsent) return + + if (!this.config.apiKey) { + console.warn('PostHog API key not found. Skipping initialization.') + return + } + + const config: Partial = { + api_host: this.config.apiHost, + autocapture: false, // We'll manually track events + capture_pageview: false, // We'll manually track pageviews + capture_pageleave: false, // We'll manually track page leaves + loaded: (posthog) => { + // Apply any pending groups + Object.entries(this.pendingGroups).forEach(([type, id]) => { + posthog.group(type, id) + }) + this.pendingGroups = {} + }, + } + + posthog.init(this.config.apiKey, config) + this.initialized = true + } + + capturePageView(properties: Record, hasConsent: boolean = true) { + if (!hasConsent || !this.initialized) return + posthog.capture('$pageview', properties) + } + + capturePageLeave(properties: Record, hasConsent: boolean = true) { + if (!hasConsent || !this.initialized) return + posthog.capture('$pageleave', properties) + } + + identify(userId: string, properties?: Record, hasConsent: boolean = true) { + if (!hasConsent || !this.initialized) return + + posthog.identify(userId, properties) + } +} + +export const posthogClient = new PostHogClient() diff --git a/packages/common/telemetry.tsx b/packages/common/telemetry.tsx index 02f6d214b1..bc89809486 100644 --- a/packages/common/telemetry.tsx +++ b/packages/common/telemetry.tsx @@ -12,9 +12,10 @@ import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from './constants' import { useFeatureFlags } from './feature-flags' import { post } from './fetchWrappers' import { ensurePlatformSuffix, isBrowser } from './helpers' -import { useTelemetryCookie } from './hooks' +import { useParams, useTelemetryCookie } from './hooks' import { TelemetryEvent } from './telemetry-constants' import { getSharedTelemetryData } from './telemetry-utils' +import { posthogClient } from './posthog-client' const { TELEMETRY_DATA } = LOCAL_STORAGE_KEYS @@ -46,14 +47,45 @@ export function handlePageTelemetry( featureFlags?: { [key: string]: unknown }, + slug?: string, + ref?: string, telemetryDataOverride?: components['schemas']['TelemetryPageBodyV2'] ) { + // Send to PostHog client-side (only in browser) + if (typeof window !== 'undefined') { + const pageData = getSharedTelemetryData(pathname) + posthogClient.capturePageView({ + $current_url: pageData.page_url, + $pathname: pageData.pathname, + $host: new URL(pageData.page_url).hostname, + $groups: { + ...(slug ? { organization: slug } : {}), + ...(ref ? { project: ref } : {}), + }, + page_title: pageData.page_title, + ...pageData.ph, + ...Object.fromEntries( + Object.entries(featureFlags || {}).map(([k, v]) => [`$feature/${k}`, v]) + ), + }) + } + + // Send to backend + // TODO: Remove this once migration to client-side page telemetry is complete return post( `${ensurePlatformSuffix(API_URL)}/telemetry/page`, telemetryDataOverride !== undefined ? { feature_flags: featureFlags, ...telemetryDataOverride } : { ...getSharedTelemetryData(pathname), + ...(slug || ref + ? { + groups: { + ...(slug ? { organization: slug } : {}), + ...(ref ? { project: ref } : {}), + }, + } + : {}), feature_flags: featureFlags, }, { headers: { Version: '2' } } @@ -65,15 +97,35 @@ export function handlePageLeaveTelemetry( 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, + }) + } + + // Send to backend + // TODO: Remove this once migration to client-side page telemetry is complete return post(`${ensurePlatformSuffix(API_URL)}/telemetry/page-leave`, { - body: { - pathname, - page_url: isBrowser ? window.location.href : '', - page_title: isBrowser ? document?.title : '', - feature_flags: featureFlags, - }, + pathname, + page_url: isBrowser ? window.location.href : '', + page_title: isBrowser ? document?.title : '', + feature_flags: featureFlags, + ...(slug || ref + ? { + groups: { + ...(slug ? { organization: slug } : {}), + ...(ref ? { project: ref } : {}), + }, + } + : {}), }) } @@ -81,16 +133,25 @@ 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() const title = typeof document !== 'undefined' ? document?.title : '' @@ -105,21 +166,43 @@ export const PageTelemetry = ({ const sendPageTelemetry = useCallback(() => { if (!(enabled && hasAcceptedConsent)) return Promise.resolve() - return handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current).catch((e) => { + return handlePageTelemetry( + API_URL, + pathnameRef.current, + featureFlagsRef.current, + slug, + ref + ).catch((e) => { console.error('Problem sending telemetry page:', e) }) - }, [API_URL, enabled, hasAcceptedConsent]) + }, [API_URL, enabled, hasAcceptedConsent, slug, ref]) const sendPageLeaveTelemetry = useCallback(() => { if (!(enabled && hasAcceptedConsent)) return Promise.resolve() - return handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current).catch((e) => { + 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]) + }, [API_URL, enabled, hasAcceptedConsent, slug, ref]) // Handle initial page telemetry event const hasSentInitialPageTelemetryRef = useRef(false) + + // 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 @@ -136,19 +219,26 @@ export const PageTelemetry = ({ try { const encodedData = telemetryCookie.split('=')[1] const telemetryData = JSON.parse(decodeURIComponent(encodedData)) - handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current, telemetryData) + handlePageTelemetry( + API_URL, + pathnameRef.current, + featureFlagsRef.current, + slug, + ref, + telemetryData + ) // remove the telemetry cookie document.cookie = `${TELEMETRY_DATA}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/` } catch (error) { console.error('Invalid telemetry data:', error) } } else { - handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current) + handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current, slug, ref) } hasSentInitialPageTelemetryRef.current = true } - }, [router?.isReady, hasAcceptedConsent, featureFlags.hasLoaded]) + }, [router?.isReady, hasAcceptedConsent, featureFlags.hasLoaded, slug, ref]) useEffect(() => { // For pages router @@ -244,9 +334,13 @@ export function useTelemetryIdentify(API_URL: string) { useEffect(() => { if (user?.id) { + // Send to backend sendTelemetryIdentify(API_URL, { user_id: user.id, }) + + // Also identify in PostHog client-side + posthogClient.identify(user.id) } }, [API_URL, user?.id]) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cbe691064..4def09c595 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1853,6 +1853,9 @@ importers: next-themes: specifier: ^0.3.0 version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + posthog-js: + specifier: ^1.257.2 + version: 1.257.2 react: specifier: 'catalog:' version: 18.3.1 @@ -10275,6 +10278,9 @@ packages: core-js@3.35.0: resolution: {integrity: sha512-ntakECeqg81KqMueeGJ79Q5ZgQNR+6eaE8sxGCx62zMbAIj65q+uYvatToew3m6eAGdU4gNZwpZ34NMe4GYswg==} + core-js@3.44.0: + resolution: {integrity: sha512-aFCtd4l6GvAXwVEh3XbbVqJGHDJt0OZRa+5ePGx3LLwi12WfexqQxcsohb2wgsa/92xtl19Hd66G/L+TaAxDMw==} + core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} @@ -11316,7 +11322,6 @@ packages: resolution: {integrity: sha512-t0q23FIpvHDTtnORW+bDJziGsal5uh9RJTJ1fyH8drd4lICOoXhJ5pLMUZ5C0VQei6dNmwTzzoTRgMkO9JgHEQ==} peerDependencies: eslint: '>= 5' - bundledDependencies: [] eslint-plugin-import@2.31.0: resolution: {integrity: sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==} @@ -11674,6 +11679,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.4.8: + resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + fflate@0.7.4: resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} @@ -15209,6 +15217,20 @@ packages: postgres-range@1.1.4: resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} + posthog-js@1.257.2: + resolution: {integrity: sha512-E+8wI/ahaiUGrmkilOtAB9aTFL+oELwOEsH1eO/2NyXB5WWcSUk6Rm1loixq8/lC4f3oR+Qqp9rHyXTSYbBDRQ==} + peerDependencies: + '@rrweb/types': 2.0.0-alpha.17 + rrweb-snapshot: 2.0.0-alpha.17 + peerDependenciesMeta: + '@rrweb/types': + optional: true + rrweb-snapshot: + optional: true + + preact@10.26.9: + resolution: {integrity: sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -18077,6 +18099,9 @@ packages: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} + web-vitals@4.2.4: + resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -29534,6 +29559,8 @@ snapshots: core-js@3.35.0: {} + core-js@3.44.0: {} + core-util-is@1.0.2: {} core-util-is@1.0.3: {} @@ -31044,6 +31071,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.2.1 + fflate@0.4.8: {} + fflate@0.7.4: {} fflate@0.8.2: {} @@ -35723,6 +35752,15 @@ snapshots: postgres-range@1.1.4: {} + posthog-js@1.257.2: + dependencies: + core-js: 3.44.0 + fflate: 0.4.8 + preact: 10.26.9 + web-vitals: 4.2.4 + + preact@10.26.9: {} + prebuild-install@7.1.3: dependencies: detect-libc: 2.0.3 @@ -39383,6 +39421,8 @@ snapshots: web-streams-polyfill@4.0.0-beta.3: {} + web-vitals@4.2.4: {} + webidl-conversions@3.0.1: {} webidl-conversions@7.0.0: