hosted: add posthog opt out + session replay

This commit is contained in:
Ben Senescu 2026-04-28 20:56:06 -04:00
parent 3e39c65265
commit 2130dccc81
14 changed files with 2290 additions and 22 deletions

View File

@ -0,0 +1 @@
ALTER TABLE `user` ADD `analytics_opted_out` integer;

File diff suppressed because it is too large Load Diff

View File

@ -71,6 +71,13 @@
"when": 1776347182067,
"tag": "0009_smart_kitty_pryde",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1777422740449,
"tag": "0010_high_liz_osborn",
"breakpoints": true
}
]
}

View File

@ -6,6 +6,7 @@ import {
CircleHelp,
CreditCard,
Menu,
Settings,
User,
} from "lucide-react";
import {
@ -13,7 +14,6 @@ import {
MissingSeoSetupModal,
SeoApiStatusBanners,
} from "@/client/layout/AppShellParts";
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { getProjectNavGroups } from "@/client/navigation/items";
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
@ -322,7 +322,9 @@ function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) {
>
{email ? (
<li className="menu-title max-w-full">
<span className="truncate text-base-content">{email}</span>
<span className="truncate text-base-content" data-ph-mask>
{email}
</span>
</li>
) : null}
{mobileOnly ? (
@ -341,6 +343,12 @@ function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) {
</a>
</li>
) : null}
<li>
<Link to="/settings" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
Settings
</Link>
</li>
{isHostedMode && email ? (
<li>
<button
@ -352,7 +360,6 @@ function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) {
</button>
</li>
) : null}
<ThemePreferenceMenuItems />
</ul>
</div>
</div>

View File

@ -7,6 +7,7 @@ type BrowserPostHogClient = typeof import("posthog-js").default;
let browserPostHogClientPromise: Promise<BrowserPostHogClient | null> | null =
null;
let browserPostHogInitialized = false;
let analyticsCaptureEnabled = true;
function getBrowserPostHogClient(): Promise<BrowserPostHogClient | null> {
if (typeof window === "undefined" || !isHostedClientAuthMode()) {
@ -34,6 +35,11 @@ function getBrowserPostHogClient(): Promise<BrowserPostHogClient | null> {
defaults: "2026-01-30",
capture_exceptions: true,
capture_pageview: "history_change",
respect_dnt: true,
session_recording: {
maskAllInputs: true,
maskTextSelector: "[data-ph-mask], .ph-mask",
},
sanitize_properties(properties, event) {
if (event === "$pageview" || event === "$pageleave") {
const url: unknown = properties["$current_url"];
@ -63,10 +69,6 @@ function getBrowserPostHogClient(): Promise<BrowserPostHogClient | null> {
return browserPostHogClientPromise;
}
export function initPostHog() {
void getBrowserPostHogClient();
}
function withPostHogClient(fn: (client: BrowserPostHogClient) => void) {
void getBrowserPostHogClient().then((client) => {
if (!client) return;
@ -78,10 +80,23 @@ function withPostHogClient(fn: (client: BrowserPostHogClient) => void) {
});
}
function withExistingPostHogClient(fn: (client: BrowserPostHogClient) => void) {
if (!browserPostHogClientPromise) return;
void browserPostHogClientPromise.then((client) => {
if (!client) return;
try {
fn(client);
} catch (e) {
console.error("posthog operation failed", e);
}
});
}
export function captureClientEvent(
event: string,
properties?: Record<string, unknown>,
) {
if (!analyticsCaptureEnabled) return;
withPostHogClient((client) => client.capture(event, properties));
}
@ -89,6 +104,7 @@ export function identifyAnalyticsUser(args: {
userId: string;
organizationId: string | null;
}) {
if (!analyticsCaptureEnabled) return;
withPostHogClient((client) => {
client.identify(args.userId);
if (args.organizationId) {
@ -98,13 +114,39 @@ export function identifyAnalyticsUser(args: {
}
export function resetAnalyticsUser() {
withPostHogClient((client) => client.reset());
withExistingPostHogClient((client) => {
client.stopSessionRecording();
client.reset();
});
}
export function stopAnalyticsCapture() {
analyticsCaptureEnabled = false;
if (!browserPostHogInitialized || !browserPostHogClientPromise) return;
void browserPostHogClientPromise.then((client) => {
if (!client) return;
try {
client.stopSessionRecording();
client.opt_out_capturing();
} catch (e) {
console.error("posthog opt-out failed", e);
}
});
}
export function startAnalyticsCapture() {
analyticsCaptureEnabled = true;
withPostHogClient((client) => {
client.opt_in_capturing();
client.startSessionRecording();
});
}
export function captureClientError(
error: unknown,
properties: Record<string, string | null | undefined> = {},
) {
if (!analyticsCaptureEnabled) return;
withPostHogClient((client) =>
client.captureException(error, {
source: "client",

View File

@ -22,6 +22,7 @@ export const user = sqliteTable("user", {
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
analyticsOptedOut: integer("analytics_opted_out", { mode: "boolean" }),
});
export const session = sqliteTable(

View File

@ -1,11 +1,18 @@
import { createAuthClient } from "better-auth/react";
import { organizationClient } from "better-auth/client/plugins";
import {
inferAdditionalFields,
organizationClient,
} from "better-auth/client/plugins";
import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog";
import { userAdditionalFields } from "@/lib/auth-options";
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
export const authClient = createAuthClient({
baseURL: typeof window !== "undefined" ? window.location.origin : "",
plugins: [organizationClient()],
plugins: [
organizationClient(),
inferAdditionalFields({ user: userAdditionalFields }),
],
});
export const { useSession } = authClient;

View File

@ -1,6 +1,15 @@
export const HOSTED_PASSWORD_MIN_LENGTH = 8;
export const HOSTED_PASSWORD_MAX_LENGTH = 128;
export const userAdditionalFields = {
analyticsOptedOut: {
type: "boolean" as const,
defaultValue: () => false,
required: false as const,
input: true as const,
},
};
export const baseAuthOptions = {
emailAndPassword: {
enabled: true,
@ -8,4 +17,7 @@ export const baseAuthOptions = {
minPasswordLength: HOSTED_PASSWORD_MIN_LENGTH,
maxPasswordLength: HOSTED_PASSWORD_MAX_LENGTH,
},
user: {
additionalFields: userAdditionalFields,
},
};

View File

@ -94,6 +94,8 @@ function getTrustedOrigins(baseUrl: string) {
trustedOrigins.push(
"http://open-seo.localhost:1355",
"http://*.open-seo.localhost:1355",
"https://open-seo.localhost:1355",
"https://*.open-seo.localhost:1355",
);
}

View File

@ -21,6 +21,7 @@ import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authentica
import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
import { Route as AppSupportRouteImport } from './routes/_app/support'
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
import { Route as AppBillingRouteImport } from './routes/_app/billing'
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
@ -97,6 +98,11 @@ const AppSupportRoute = AppSupportRouteImport.update({
path: '/support',
getParentRoute: () => AppRouteRoute,
} as any)
const AppSettingsRoute = AppSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => AppRouteRoute,
} as any)
const AppBillingRoute = AppBillingRouteImport.update({
id: '/billing',
path: '/billing',
@ -208,6 +214,7 @@ export interface FileRoutesByFullPath {
'/reset-password': typeof ResetPasswordRoute
'/verify-email': typeof VerifyEmailRoute
'/billing': typeof AppBillingRoute
'/settings': typeof AppSettingsRoute
'/support': typeof AppSupportRoute
'/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute
@ -237,6 +244,7 @@ export interface FileRoutesByTo {
'/reset-password': typeof ResetPasswordRoute
'/verify-email': typeof VerifyEmailRoute
'/billing': typeof AppBillingRoute
'/settings': typeof AppSettingsRoute
'/support': typeof AppSupportRoute
'/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute
@ -267,6 +275,7 @@ export interface FileRoutesById {
'/reset-password': typeof ResetPasswordRoute
'/verify-email': typeof VerifyEmailRoute
'/_app/billing': typeof AppBillingRoute
'/_app/settings': typeof AppSettingsRoute
'/_app/support': typeof AppSupportRoute
'/_auth/sign-in': typeof AuthSignInRoute
'/_auth/sign-up': typeof AuthSignUpRoute
@ -299,6 +308,7 @@ export interface FileRouteTypes {
| '/reset-password'
| '/verify-email'
| '/billing'
| '/settings'
| '/support'
| '/sign-in'
| '/sign-up'
@ -328,6 +338,7 @@ export interface FileRouteTypes {
| '/reset-password'
| '/verify-email'
| '/billing'
| '/settings'
| '/support'
| '/sign-in'
| '/sign-up'
@ -357,6 +368,7 @@ export interface FileRouteTypes {
| '/reset-password'
| '/verify-email'
| '/_app/billing'
| '/_app/settings'
| '/_app/support'
| '/_auth/sign-in'
| '/_auth/sign-up'
@ -480,6 +492,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppSupportRouteImport
parentRoute: typeof AppRouteRoute
}
'/_app/settings': {
id: '/_app/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof AppSettingsRouteImport
parentRoute: typeof AppRouteRoute
}
'/_app/billing': {
id: '/_app/billing'
path: '/billing'
@ -618,6 +637,7 @@ declare module '@tanstack/react-router' {
interface AppRouteRouteChildren {
AppBillingRoute: typeof AppBillingRoute
AppSettingsRoute: typeof AppSettingsRoute
AppSupportRoute: typeof AppSupportRoute
AppIndexRoute: typeof AppIndexRoute
AppHelpDataforseoApiKeyRoute: typeof AppHelpDataforseoApiKeyRoute
@ -625,6 +645,7 @@ interface AppRouteRouteChildren {
const AppRouteRouteChildren: AppRouteRouteChildren = {
AppBillingRoute: AppBillingRoute,
AppSettingsRoute: AppSettingsRoute,
AppSupportRoute: AppSupportRoute,
AppIndexRoute: AppIndexRoute,
AppHelpDataforseoApiKeyRoute: AppHelpDataforseoApiKeyRoute,

View File

@ -14,8 +14,9 @@ import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary";
import { themePreferenceInitScript } from "@/client/lib/theme";
import {
identifyAnalyticsUser,
initPostHog,
resetAnalyticsUser,
startAnalyticsCapture,
stopAnalyticsCapture,
} from "@/client/lib/posthog";
import { NotFound } from "@/client/components/NotFound";
import appCss from "@/client/styles/app.css?url";
@ -85,6 +86,7 @@ function PostHogBootstrap() {
const isHostedMode = isHostedClientAuthMode();
const { data: session, isPending: isSessionPending } = useSession();
const userId = session?.user?.id ?? null;
const optedOut = session?.user?.analyticsOptedOut === true;
const organizationId = getActiveOrganizationId(session);
const previousUserIdRef = React.useRef<string | null>(null);
@ -93,16 +95,17 @@ function PostHogBootstrap() {
return;
}
initPostHog();
if (userId) {
if (userId && !optedOut) {
startAnalyticsCapture();
identifyAnalyticsUser({ userId, organizationId });
previousUserIdRef.current = userId;
} else if (userId && optedOut) {
stopAnalyticsCapture();
} else if (previousUserIdRef.current) {
previousUserIdRef.current = null;
resetAnalyticsUser();
}
}, [isHostedMode, isSessionPending, organizationId, userId]);
}, [isHostedMode, isSessionPending, optedOut, organizationId, userId]);
return null;
}

View File

@ -0,0 +1,119 @@
import { createFileRoute } from "@tanstack/react-router";
import { Monitor, Moon, Sun } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { type ThemePreference, useThemePreference } from "@/client/lib/theme";
import { authClient, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
export const Route = createFileRoute("/_app/settings")({
component: SettingsPage,
});
const THEME_OPTIONS: {
value: ThemePreference;
label: string;
icon: typeof Sun;
}[] = [
{ value: "system", label: "System", icon: Monitor },
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
];
function SettingsPage() {
const isHosted = isHostedClientAuthMode();
const { themePreference, setThemePreference } = useThemePreference();
const { data: session, isPending: isSessionPending } = useSession();
const [isSaving, setIsSaving] = useState(false);
const analyticsEnabled = session?.user?.analyticsOptedOut !== true;
async function updateAnalyticsPreference(enabled: boolean) {
setIsSaving(true);
try {
const result = await authClient.updateUser({
analyticsOptedOut: !enabled,
});
if (result.error) {
toast.error("We couldn't update your analytics setting.");
} else {
toast.success(enabled ? "Analytics enabled" : "Analytics disabled");
}
} catch {
toast.error("We couldn't update your analytics setting.");
} finally {
setIsSaving(false);
}
}
return (
<div className="h-full overflow-auto bg-base-100 px-4 py-8 pb-24 md:px-6 md:py-12 md:pb-8">
<div className="mx-auto max-w-xl space-y-10">
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<section className="space-y-3">
<h2 className="text-sm font-medium text-base-content/50">
Appearance
</h2>
<div className="flex items-center justify-between gap-6">
<span className="text-sm">Theme</span>
<div
role="radiogroup"
aria-label="Theme preference"
className="flex gap-0.5 rounded-lg bg-base-200 p-0.5"
>
{THEME_OPTIONS.map((option) => {
const isActive = option.value === themePreference;
const Icon = option.icon;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={isActive}
aria-label={option.label}
className={`flex cursor-pointer items-center justify-center rounded-md px-3 py-1.5 transition-colors ${
isActive
? "bg-base-100 text-base-content shadow-sm"
: "text-base-content/50 hover:text-base-content/80"
}`}
onClick={() => setThemePreference(option.value)}
>
<Icon className="size-4" />
</button>
);
})}
</div>
</div>
</section>
{isHosted ? (
<section className="space-y-3">
<h2 className="text-sm font-medium text-base-content/50">
Analytics
</h2>
<div className="flex items-start justify-between gap-6">
<div>
<p className="text-sm">Help improve OpenSEO</p>
<p className="mt-1 text-sm text-base-content/60">
Share analytics and usage data.
</p>
</div>
<input
type="checkbox"
className="toggle toggle-primary"
checked={analyticsEnabled}
disabled={isSessionPending || isSaving || !session?.user}
onChange={(event) => {
void updateAnalyticsPreference(event.currentTarget.checked);
}}
aria-label="Enable product analytics"
/>
</div>
</section>
) : null}
</div>
</div>
);
}

View File

@ -1,7 +1,7 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { AutumnProvider, useCustomer } from "autumn-js/react";
import { useEffect, useState } from "react";
import { ArrowRight, User } from "lucide-react";
import { ArrowRight, Settings, User } from "lucide-react";
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { captureClientEvent } from "@/client/lib/posthog";
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
@ -235,7 +235,15 @@ function SubscribePageAccountMenu({ email }: { email: string | undefined }) {
className="dropdown-content z-20 menu mt-3 min-w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li className="menu-title max-w-full">
<span className="truncate text-base-content">{email}</span>
<span className="truncate text-base-content" data-ph-mask>
{email}
</span>
</li>
<li>
<Link to="/settings" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
Settings
</Link>
</li>
<ThemePreferenceMenuItems />
<li>

File diff suppressed because one or more lines are too long