Skip to content

Commit 63de459

Browse files
authored
Merge pull request #7841 from logto-io/gao-init-posthog
feat(console): init posthog
2 parents 3ed4d0a + 9136699 commit 63de459

File tree

10 files changed

+356
-234
lines changed

10 files changed

+356
-234
lines changed

packages/console/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"overlayscrollbars-react": "^0.5.0",
9090
"postcss": "^8.4.39",
9191
"postcss-modules": "^6.0.0",
92+
"posthog-js": "^1.268.6",
9293
"prettier": "^3.5.3",
9394
"prism-react-renderer": "^2.4.1",
9495
"prop-types": "^15.8.1",

packages/console/src/App.tsx

Lines changed: 110 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import {
77
TenantScope,
88
} from '@logto/schemas';
99
import { conditionalArray } from '@silverhand/essentials';
10-
import { useContext, useMemo } from 'react';
10+
import { PostHogProvider, usePostHog } from 'posthog-js/react';
11+
import { useContext, useEffect, useMemo } from 'react';
1112
import { Helmet } from 'react-helmet';
1213
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
1314

@@ -21,7 +22,7 @@ import 'react-color-palette/css';
2122

2223
import CloudAppRoutes from '@/cloud/AppRoutes';
2324
import AppLoading from '@/components/AppLoading';
24-
import { isCloud } from '@/consts/env';
25+
import { isCloud, postHogHost, postHogKey } from '@/consts/env';
2526
import { cloudApi, getManagementApi, meApi } from '@/consts/resources';
2627
import { ConsoleRoutes } from '@/containers/ConsoleRoutes';
2728

@@ -71,8 +72,6 @@ export default App;
7172
* different components.
7273
*/
7374
function Providers() {
74-
const { currentTenantId } = useContext(TenantsContext);
75-
7675
// For Cloud, we use Management API proxy for accessing tenant data.
7776
// For OSS, we directly call the tenant API with the default tenant API resource.
7877
const resources = useMemo(
@@ -103,58 +102,123 @@ function Providers() {
103102
);
104103

105104
return (
106-
<LogtoProvider
107-
unstable_enableCache
108-
config={{
109-
endpoint: adminTenantEndpoint.href,
110-
appId: adminConsoleApplicationId,
111-
resources,
112-
scopes,
113-
prompt: [Prompt.Login, Prompt.Consent],
105+
<PostHogProvider
106+
apiKey={postHogKey ?? ''} // Empty key will disable PostHog
107+
options={{
108+
api_host: postHogHost,
109+
defaults: '2025-05-24',
114110
}}
115111
>
116-
<AppThemeProvider>
117-
<Helmet titleTemplate={`%s - ${mainTitle}`} defaultTitle={mainTitle} />
118-
<Toast />
119-
<AppConfirmModalProvider>
120-
<ErrorBoundary>
121-
<LogtoErrorBoundary>
122-
{/**
123-
* If it's not Cloud (OSS), render the tenant app container directly since only default tenant is available;
124-
* if it's Cloud, render the tenant app container only when a tenant ID is available (in a tenant context).
125-
*/}
126-
{!isCloud || currentTenantId ? (
112+
<LogtoProvider
113+
unstable_enableCache
114+
config={{
115+
endpoint: adminTenantEndpoint.href,
116+
appId: adminConsoleApplicationId,
117+
resources,
118+
scopes,
119+
prompt: [Prompt.Login, Prompt.Consent],
120+
}}
121+
>
122+
<AppThemeProvider>
123+
<Helmet titleTemplate={`%s - ${mainTitle}`} defaultTitle={mainTitle} />
124+
<Toast />
125+
<AppConfirmModalProvider>
126+
<ErrorBoundary>
127+
<LogtoErrorBoundary>
127128
<AppDataProvider>
128-
<AppRoutes />
129+
<GlobalScripts />
130+
<Content />
129131
</AppDataProvider>
130-
) : (
131-
<CloudAppRoutes />
132-
)}
133-
</LogtoErrorBoundary>
134-
</ErrorBoundary>
135-
</AppConfirmModalProvider>
136-
</AppThemeProvider>
137-
</LogtoProvider>
132+
</LogtoErrorBoundary>
133+
</ErrorBoundary>
134+
</AppConfirmModalProvider>
135+
</AppThemeProvider>
136+
</LogtoProvider>
137+
</PostHogProvider>
138138
);
139139
}
140140

141-
/** Renders different routes based on the user's onboarding status. */
142-
function AppRoutes() {
141+
function Content() {
143142
const { tenantEndpoint } = useContext(AppDataContext);
144-
const { isLoaded } = useCurrentUser();
143+
const { isLoaded, user } = useCurrentUser();
145144
const { isAuthenticated } = useLogto();
145+
const { currentTenantId, currentTenant } = useContext(TenantsContext);
146+
const postHog = usePostHog();
147+
148+
useEffect(() => {
149+
if (isLoaded) {
150+
postHog.identify(user?.id);
151+
}
152+
// We don't reset user info here because this component includes some anonymous pages.
153+
// Resetting user info may cause issues when the user switches between anonymous and
154+
// authenticated pages.
155+
// Reset user info in the sign-out logic instead.
156+
}, [isLoaded, postHog, user]);
157+
158+
/**
159+
* The `useEffect` below sets the PostHog group properties based on:
160+
*
161+
* 1. If `currentTenant` is available, since it contains rich data, set the group with both ID
162+
* and necessary properties.
163+
* 2. If only `currentTenantId` is available, set the group with only ID. This usually happens
164+
* when the URL contains a tenant ID but the tenant data is not loaded yet or the tenant is
165+
* unavailable to the user.
166+
* 3. If neither is available, reset all group properties. This usually happens when the user is
167+
* not in a tenant context.
168+
*
169+
* @caveat
170+
* We need to identify group when window is reactivated (tab switch or window switch)
171+
* since one user may access different tenants in different tabs or windows.
172+
*
173+
* Currently, PostHog DOES capture the correct group when the user switches tabs or windows
174+
* since it reads the properties from the memory if existing, but just in case it doesn't work
175+
* in the future, we add this logic here.
176+
*
177+
* See {https://github.com/PostHog/posthog-js/blob/b5eb605/packages/core/src/posthog-core-stateless.ts#L778-L783 | posthog-js source code}
178+
* for details at the time of writing.
179+
*/
180+
useEffect(() => {
181+
const captureGroups = () => {
182+
if (currentTenant) {
183+
postHog.group('tenant', currentTenantId, {
184+
name: currentTenant.name,
185+
});
186+
} else if (currentTenantId) {
187+
postHog.group('tenant', currentTenantId);
188+
} else {
189+
postHog.resetGroups();
190+
}
191+
};
192+
193+
captureGroups();
194+
195+
const handleVisibilityChange = () => {
196+
if (document.visibilityState === 'visible') {
197+
captureGroups();
198+
}
199+
};
146200

147-
// Authenticated user should load onboarding data before rendering the app.
148-
// This looks weird and it will be refactored soon by merging the onboarding
149-
// routes with the console routes.
150-
if (!tenantEndpoint || (isCloud && isAuthenticated && !isLoaded)) {
151-
return <AppLoading />;
201+
document.addEventListener('visibilitychange', handleVisibilityChange);
202+
203+
return () => {
204+
document.removeEventListener('visibilitychange', handleVisibilityChange);
205+
};
206+
}, [postHog, currentTenantId, currentTenant]);
207+
208+
/**
209+
* If it's not Cloud (OSS), render the tenant app container directly since only default tenant is available;
210+
* if it's Cloud, render the tenant app container only when a tenant ID is available (in a tenant context).
211+
*/
212+
if (!isCloud || currentTenantId) {
213+
// Authenticated user should load onboarding data before rendering the app.
214+
// This looks weird and it can be refactored by merging the onboarding
215+
// routes with the console routes.
216+
if (!tenantEndpoint || (isCloud && isAuthenticated && !isLoaded)) {
217+
return <AppLoading />;
218+
}
219+
220+
return <ConsoleRoutes />;
152221
}
153222

154-
return (
155-
<>
156-
<GlobalScripts />
157-
<ConsoleRoutes />
158-
</>
159-
);
223+
return <CloudAppRoutes />;
160224
}

packages/console/src/components/Topbar/UserInfo/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { builtInLanguageOptions as consoleBuiltInLanguageOptions } from '@logto/phrases';
2-
import { useLogto } from '@logto/react';
32
import { Theme } from '@logto/schemas';
43
import classNames from 'classnames';
54
import { useRef, useState } from 'react';
@@ -20,6 +19,7 @@ import Spacer from '@/ds-components/Spacer';
2019
import { Ring as Spinner } from '@/ds-components/Spinner';
2120
import useCurrentUser from '@/hooks/use-current-user';
2221
import useRedirectUri from '@/hooks/use-redirect-uri';
22+
import useSignOut from '@/hooks/use-sign-out';
2323
import useTenantPathname from '@/hooks/use-tenant-pathname';
2424
import useUserPreferences from '@/hooks/use-user-preferences';
2525
import { DynamicAppearanceMode } from '@/types/appearance-mode';
@@ -30,7 +30,7 @@ import UserInfoSkeleton from './UserInfoSkeleton';
3030
import styles from './index.module.scss';
3131

3232
function UserInfo() {
33-
const { signOut } = useLogto();
33+
const { signOut } = useSignOut();
3434
const { getUrl } = useTenantPathname();
3535
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
3636
const { user, isLoading: isLoadingUser } = useCurrentUser();

packages/console/src/consts/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,5 @@ export const consoleEmbeddedPricingUrl =
1919
'https://logto.io/console-embedded-pricing';
2020

2121
export const inkeepApiKey = normalizeEnv(import.meta.env.INKEEP_API_KEY);
22+
export const postHogKey = normalizeEnv(import.meta.env.POSTHOG_PUBLIC_KEY);
23+
export const postHogHost = normalizeEnv(import.meta.env.POSTHOG_PUBLIC_HOST);

packages/console/src/contexts/TenantsProvider.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,13 @@ type Tenants = {
6363
removeTenant: (tenantId: string) => void;
6464
/** Update a tenant by ID if it exists in the current tenants data. */
6565
updateTenant: (tenantId: string, data: Partial<TenantResponse>) => void;
66-
/** The current tenant ID parsed from the URL. */
66+
/**
67+
* The current tenant ID parsed from the URL.
68+
*
69+
* - If it's a non-cloud deployment, it will always be `default`.
70+
* - For cloud deployment, if it's `''`, the user is not in a tenant context (e.g. in onboarding
71+
* routes).
72+
*/
6773
currentTenantId: string;
6874
currentTenant?: TenantResponse;
6975
/** Indicates if the current tenant is a development tenant. */

packages/console/src/hooks/use-api.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import { TenantsContext } from '@/contexts/TenantsProvider';
2727
import { useConfirmModal } from '@/hooks/use-confirm-modal';
2828
import useRedirectUri from '@/hooks/use-redirect-uri';
2929

30+
import useSignOut from './use-sign-out';
31+
3032
export class RequestError extends Error {
3133
constructor(
3234
public readonly status: number,
@@ -45,7 +47,7 @@ export type StaticApiProps = {
4547
};
4648

4749
const useGlobalRequestErrorHandler = (toastDisabledErrorCodes?: LogtoErrorCode[]) => {
48-
const { signOut } = useLogto();
50+
const { signOut } = useSignOut();
4951
const { show } = useConfirmModal();
5052
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
5153

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { useLogto } from '@logto/react';
2+
import { usePostHog } from 'posthog-js/react';
3+
import { useCallback } from 'react';
4+
5+
/**
6+
* A hook that returns a wrapped `signOut` function from `useLogto` with necessary cleanup logic.
7+
*
8+
* Unless you have special needs, you should always use this hook instead of `useLogto` directly.
9+
*/
10+
const useSignOut = () => {
11+
const { signOut: logtoSignOut } = useLogto();
12+
const postHog = usePostHog();
13+
14+
const signOut = useCallback<ReturnType<typeof useLogto>['signOut']>(
15+
async (postSignOutRedirectUri) => {
16+
postHog.resetGroups(); // Not sure if this is needed, but just in case.
17+
postHog.reset();
18+
return logtoSignOut(postSignOutRedirectUri);
19+
},
20+
[logtoSignOut, postHog]
21+
);
22+
return {
23+
/** A wrapped version of `useLogto`'s `signOut` with necessary cleanup logic. */
24+
signOut,
25+
};
26+
};
27+
28+
export default useSignOut;

packages/console/src/pages/Profile/containers/DeleteAccountModal/components/FinalConfirmationModal/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { useLogto } from '@logto/react';
21
import { ResponseError } from '@withtyped/client';
32
import { useContext, useState } from 'react';
43
import { useTranslation } from 'react-i18next';
@@ -10,6 +9,7 @@ import { TenantsContext } from '@/contexts/TenantsProvider';
109
import Button from '@/ds-components/Button';
1110
import ModalLayout from '@/ds-components/ModalLayout';
1211
import useRedirectUri from '@/hooks/use-redirect-uri';
12+
import useSignOut from '@/hooks/use-sign-out';
1313
import modalStyles from '@/scss/modal.module.scss';
1414

1515
import styles from '../../index.module.scss';
@@ -29,7 +29,7 @@ export default function FinalConfirmationModal({
2929
onClose,
3030
}: Props) {
3131
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.profile.delete_account' });
32-
const { signOut } = useLogto();
32+
const { signOut } = useSignOut();
3333
const { removeTenant } = useContext(TenantsContext);
3434
const postSignOutRedirectUri = useRedirectUri('signOut');
3535
const [isDeleting, setIsDeleting] = useState(false);

packages/console/vite.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ const buildConfig = (mode: string): UserConfig => ({
5252
process.env.CONSOLE_EMBEDDED_PRICING_URL
5353
),
5454
'import.meta.env.INKEEP_API_KEY': JSON.stringify(process.env.INKEEP_API_KEY),
55+
'import.meta.env.POSTHOG_PUBLIC_KEY': JSON.stringify(process.env.POSTHOG_PUBLIC_KEY),
56+
'import.meta.env.POSTHOG_PUBLIC_HOST': JSON.stringify(process.env.POSTHOG_PUBLIC_HOST),
5557
// `@withtyped/client` needs this to be defined. We can optimize this later.
5658
'process.env': {},
5759
},

0 commit comments

Comments
 (0)