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: