hosted: add product analytics (#83)

* track core product analytics flows

Track auth, search, export, audit, and credit-consumption events with canonical route IDs so PostHog funnels and usage dashboards stay low-noise and privacy-safe.

* fix: keep auth actions usable after session loss

* refactor: simplify analytics and auth helpers

- Replace isRecord/getActiveOrganizationId type guards with simple cast
- Refactor getAnalyticsRouteContext from if/return chain to route tables
- Replace toVerificationIssueType switch with zod enum
- Merge duplicate credits_consume events into single event per API call
- Merge two PostHogBootstrap useEffects into one

* refactor: add projectId to middleware context to reduce boilerplate

The requireProjectContext middleware now includes projectId directly,
eliminating repeated manual construction of BillingCustomerContext
objects across all server function handlers.

* remove unused BILLING_* env var fallbacks from cost profile script

* remove before_send event enrichment to preserve native PostHog URL tracking

The before_send hook was stripping $pathname, $current_url, $referrer and
other URL properties, which breaks PostHog web analytics dashboards, paths
analysis, session replay, and attribution. The route_id/route_group injection
it provided is unnecessary since PostHog already captures $pathname natively.

* remove route mapping layer, pass raw redirect paths to analytics events

The route ID registry (STATIC_ROUTES, PROJECT_ROUTES, getAnalyticsRouteContext,
getRedirectRouteId) duplicated what PostHog already captures via $pathname.
Replace redirect_route_id with redirect_to containing the raw path, and remove
~80 lines of route mapping infrastructure.

* clean up analytics events: drop redundant submit events and derived properties

- Remove search_submit events for keywords, domain overview, and backlinks
  (the search_complete events capture the meaningful outcome data)
- Remove target_type from backlinks events (derived 1:1 from search_scope)
- Remove result_limit from keyword research (requested limit, not useful
  alongside actual result_count)
- Remove export_format from data:export events (always "csv")

* refactor: inline wrappers, colocate helpers, deduplicate getActiveOrganizationId

- Inline toVerificationIssueType into verify-email.tsx (single-use wrapper)
- Move mapDataforseoPathToCreditFeature into dataforseoClient.ts (only consumer)
- Extract shared getActiveOrganizationId into lib/auth-session.ts (was
  duplicated in __root.tsx and middleware/ensure-user/hosted.ts)
- Rename shared/analytics.ts → shared/internal-user.ts (only email helpers
  remain after removing route mapping, verification, and dataforseo helpers)

* remove internal user tracking and email domain properties

Drop is_internal_user super property, email_domain person property, and all
supporting code (shared/internal-user.ts, getEmailDomain, isInternalUserEmail).
Simplifies initPostHog and identifyAnalyticsUser signatures.

* remove backlinks:search_complete effect-based tracking

The reactive useEffect + useRef dedup pattern added ~30 lines of plumbing
inside a data hook for a single analytics event. Not worth the complexity.

* simplify: replace manual type guards with zod, deduplicate posthog and sign-out helpers

- Replace hand-rolled typeof checks in getActiveOrganizationId and
  isAuthenticatedServerFunctionContext with zod safeParse
- Extract withPostHogClient helper to deduplicate client posthog wrapper
- Move apiKey guard into getServerPostHogClient factory
- Extract signOutAndRedirect to avoid duplicated sign-out logic
- Drop derivable has_results from analytics events
- Remove unnecessary path normalization in mapDataforseoPathToCreditFeature

* fix: strip email from pageview URLs, restore sign-out guard, harden server posthog, fix path mapper

- Sanitize $current_url on pageviews to remove email query param (PII)
- Restore onSuccess for sign-out redirect to avoid bounce-back on failure
- Swallow shutdown() errors so PostHog outages can't fail billed work
- Rewrite mapDataforseoPathToCreditFeature to match real API path structure
  (path[1] = module, path[3] = endpoint) instead of scanning all segments

* simplify: remove redundant refs in verify-email, infer middleware context type

- Remove unnecessary useRef guards in verify-email effects (deps already prevent re-firing)
- Use z.ZodType<EnsuredUserContext> annotation to infer return type instead of casting
- Add comment explaining one-shot PostHog client on Workers

* fix: reset PostHog identity on sign-out before redirect

* fix: require POSTHOG_HOST env var instead of defaulting to us.i.posthog.com

* fix: annotate url as unknown to satisfy no-unsafe-assignment

* format
This commit is contained in:
Ben Senescu 2026-04-07 00:10:46 -04:00 committed by Ben Senescu
parent f3ef909d3e
commit ad3b732f60
35 changed files with 539 additions and 179 deletions

View File

@ -105,12 +105,9 @@ function buildBillingCustomer(
cliArgs: Record<string, string>,
): BillingCustomerContext {
return {
organizationId:
cliArgs.organizationId ?? process.env.BILLING_ORGANIZATION_ID ?? "local",
userEmail:
cliArgs.userEmail ??
process.env.BILLING_USER_EMAIL ??
"local@example.com",
organizationId: cliArgs.organizationId ?? "local",
userId: cliArgs.userId ?? "local-user",
userEmail: cliArgs.userEmail ?? "local@example.com",
};
}

View File

@ -1,6 +1,5 @@
import { z } from "zod";
import { normalizeAuthRedirect } from "@/lib/auth-redirect";
import { useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import {
getFieldError as getSharedFieldError,
@ -13,13 +12,11 @@ export const authRedirectSearchSchema = z.object({
export function useAuthPageState(redirect: string | undefined) {
const redirectTo = normalizeAuthRedirect(redirect);
const { isPending: isSessionPending } = useSession();
const isHostedMode = isHostedClientAuthMode();
return {
redirectTo,
isHostedMode,
isSessionPending,
};
}

View File

@ -54,14 +54,16 @@ export function BacklinksSearchCard({
},
onSubmit: ({ value }) => {
const target = value.target.trim();
onSubmit({
...value,
target,
scope: resolveBacklinksSearchScope({
const scope = resolveBacklinksSearchScope({
target,
selectedScope: value.scope,
userSelectedScope,
}),
});
onSubmit({
...value,
target,
scope,
});
},
});

View File

@ -18,6 +18,7 @@ import {
keywordsToCsv,
pagesToCsv,
} from "@/client/features/domain/utils";
import { captureClientEvent } from "@/client/lib/posthog";
import type {
DomainActiveTab,
DomainOverviewData,
@ -87,6 +88,13 @@ export function DomainResultsCard({
? keywordsToCsv(filteredKeywords)
: pagesToCsv(filteredPages);
downloadCsv(rows, `${overview.domain}-${activeTab}.${extension}`);
if (extension === "csv") {
captureClientEvent("data:export", {
source_feature: "domain_overview",
result_count: currentRows.length,
});
}
};
const isKeywordsTab = activeTab === "keywords";

View File

@ -1,5 +1,6 @@
import { toast } from "sonner";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import type { DomainOverviewData } from "@/client/features/domain/types";
type SaveMutation = (payload: {
@ -54,6 +55,10 @@ export function saveSelectedKeywords({
},
{
onSuccess: () => {
captureClientEvent("keyword:save", {
source_feature: "domain_overview",
keyword_count: selectedKeywords.size,
});
toast.success(`Saved ${selectedKeywords.size} keywords`);
},
onError: (error: unknown) => {

View File

@ -5,6 +5,7 @@ import { sortBy } from "remeda";
import { toast } from "sonner";
import { getDomainOverview } from "@/serverFunctions/domain";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import { filterAndSortKeywords } from "@/client/features/domain/domainFiltering";
import {
getDefaultSortOrder,
@ -293,6 +294,12 @@ export function useSearchRunner({
languageCode: "en",
});
captureClientEvent("domain_overview:search_complete", {
sort_mode: activeSort,
include_subdomains: activeSubdomains,
result_count: response.keywords.length,
});
setOverview(response);
setSelectedKeywords(new Set());
addSearch({

View File

@ -1,6 +1,7 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import { LOCATIONS, getLanguageCode } from "@/client/features/keywords/utils";
import { researchKeywords } from "@/serverFunctions/keywords";
import type {
@ -75,11 +76,19 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
},
{
onSuccess: (result) => {
const resultCount = result.rows.length;
setResearchError(null);
setRows(result.rows);
setLastResultSource(result.source);
setLastUsedFallback(result.usedFallback);
captureClientEvent("keyword_research:search_complete", {
location_code: input.locationCode,
search_mode: input.mode,
result_count: resultCount,
});
if (seedKeyword) {
addSearch(
seedKeyword,

View File

@ -1,6 +1,7 @@
import { toast } from "sonner";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import { getLanguageCode } from "@/client/features/keywords/utils";
import type { KeywordResearchRow } from "@/types/keywords";
import type { SortDir, SortField } from "@/client/features/keywords/components";
@ -74,6 +75,10 @@ export function useSaveAndExportActions(params: SaveExportActionParams) {
},
{
onSuccess: () => {
captureClientEvent("keyword:save", {
source_feature: "keyword_research",
keyword_count: selectedRows.size,
});
toast.success(`Saved ${selectedRows.size} keywords`);
setShowSaveDialog(false);
},
@ -111,6 +116,10 @@ export function useSaveAndExportActions(params: SaveExportActionParams) {
]);
const csv = buildCsv(headers, csvRows);
downloadCsv("keyword-research.csv", csv);
captureClientEvent("data:export", {
source_feature: "keyword_research",
result_count: source.length,
});
};
return { handleSaveKeywords, confirmSave, exportCsv };

View File

@ -5,6 +5,7 @@ import { useLocalKeywordFilters } from "@/client/features/keywords/hooks/useLoca
import { useKeywordResearchData } from "@/client/features/keywords/hooks/useKeywordResearchData";
import { useKeywordSelection } from "@/client/features/keywords/hooks/useKeywordSelection";
import { useKeywordSerpAnalysis } from "@/client/features/keywords/hooks/useKeywordSerpAnalysis";
import { captureClientEvent } from "@/client/lib/posthog";
import { useSearchHistory } from "@/client/hooks/useSearchHistory";
import {
type KeywordMode,
@ -88,6 +89,7 @@ export function useKeywordResearchController(
};
const handleRowClick = (row: KeywordResearchRow) => {
captureClientEvent("keyword_research:serp_open");
state.setSelectedKeyword(row);
state.setSerpKeyword(row.keyword);
state.setSerpPage(0);

View File

@ -14,8 +14,7 @@ import {
} from "@/client/layout/AppShellParts";
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { getProjectNavItems } from "@/client/navigation/items";
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
import { authClient, useSession } from "@/lib/auth-client";
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { BILLING_ROUTE } from "@/shared/billing";
import { getSeoApiKeyStatus } from "@/serverFunctions/config";
@ -244,16 +243,7 @@ function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) {
const isHostedMode = isHostedClientAuthMode();
const email = session?.user?.email;
const handleSignOut = () => {
const signInHref = getSignInHrefForLocation(window.location);
void authClient.signOut({
fetchOptions: {
onSuccess: () => {
window.location.assign(signInHref);
},
},
});
};
const handleSignOut = () => signOutAndRedirect();
const menu = (
<div className={mobileOnly ? "ml-2 flex-none md:hidden" : "flex-none"}>

View File

@ -22,17 +22,33 @@ function getBrowserPostHogClient(): Promise<BrowserPostHogClient | null> {
.then((module) => {
const client = module.default;
const apiKey = import.meta.env.POSTHOG_PUBLIC_KEY?.trim();
const host = import.meta.env.POSTHOG_HOST?.trim();
if (!apiKey) {
if (!apiKey || !host) {
return null;
}
if (!browserPostHogInitialized) {
client.init(apiKey, {
api_host:
import.meta.env.POSTHOG_HOST?.trim() || "https://us.i.posthog.com",
api_host: host,
defaults: "2026-01-30",
capture_exceptions: true,
capture_pageview: "history_change",
sanitize_properties(properties, event) {
if (event === "$pageview" || event === "$pageleave") {
const url: unknown = properties["$current_url"];
if (typeof url === "string") {
try {
const parsed = new URL(url);
parsed.searchParams.delete("email");
properties["$current_url"] = parsed.toString();
} catch {
// leave as-is if URL parsing fails
}
}
}
return properties;
},
});
browserPostHogInitialized = true;
}
@ -51,22 +67,48 @@ export function initPostHog() {
void getBrowserPostHogClient();
}
function withPostHogClient(fn: (client: BrowserPostHogClient) => void) {
void getBrowserPostHogClient().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>,
) {
withPostHogClient((client) => client.capture(event, properties));
}
export function identifyAnalyticsUser(args: {
userId: string;
organizationId: string | null;
}) {
withPostHogClient((client) => {
client.identify(args.userId);
if (args.organizationId) {
client.group("organization", args.organizationId);
}
});
}
export function resetAnalyticsUser() {
withPostHogClient((client) => client.reset());
}
export function captureClientError(
error: unknown,
properties: Record<string, string | null | undefined> = {},
) {
void getBrowserPostHogClient().then((client) => {
if (!client) {
return;
}
try {
withPostHogClient((client) =>
client.captureException(error, {
source: "client",
...properties,
});
} catch (e) {
console.error("posthog capture failed", e);
}
});
}),
);
}

View File

@ -1,5 +1,7 @@
import { createAuthClient } from "better-auth/react";
import { organizationClient } from "better-auth/client/plugins";
import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog";
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
export const authClient = createAuthClient({
baseURL: typeof window !== "undefined" ? window.location.origin : "",
@ -7,3 +9,16 @@ export const authClient = createAuthClient({
});
export const { useSession } = authClient;
export function signOutAndRedirect() {
const signInHref = getSignInHrefForLocation(window.location);
captureClientEvent("auth:sign_out");
resetAnalyticsUser();
void authClient.signOut({
fetchOptions: {
onSuccess: () => {
window.location.assign(signInHref);
},
},
});
}

12
src/lib/auth-session.ts Normal file
View File

@ -0,0 +1,12 @@
import { z } from "zod";
const sessionWithOrg = z.object({
session: z.object({
activeOrganizationId: z.string(),
}),
});
export function getActiveOrganizationId(session: unknown): string | null {
const result = sessionWithOrg.safeParse(session);
return result.success ? result.data.session.activeOrganizationId : null;
}

View File

@ -1,20 +1,9 @@
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
import { getActiveOrganizationId } from "@/lib/auth-session";
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
import { AppError } from "@/server/lib/errors";
import type { EnsuredUserContext } from "./types";
function getActiveOrganizationId(session: { session: unknown }) {
if (!session.session || typeof session.session !== "object") {
return null;
}
const { activeOrganizationId } = session.session as {
activeOrganizationId?: unknown;
};
return typeof activeOrganizationId === "string" ? activeOrganizationId : null;
}
async function requireHostedSession(headers: Headers) {
if (!hasHostedAuthConfig()) {
throw new AppError(

View File

@ -12,12 +12,18 @@ import { QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary";
import { themePreferenceInitScript } from "@/client/lib/theme";
import { initPostHog } from "@/client/lib/posthog";
import {
identifyAnalyticsUser,
initPostHog,
resetAnalyticsUser,
} from "@/client/lib/posthog";
import { NotFound } from "@/client/components/NotFound";
import appCss from "@/client/styles/app.css?url";
import { useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { Toaster } from "sonner";
import { queryClient } from "@/client/tanstack-db";
import { getActiveOrganizationId } from "@/lib/auth-session";
export const Route = createRootRoute({
head: () => ({
@ -77,14 +83,26 @@ function AppLayout() {
function PostHogBootstrap() {
const isHostedMode = isHostedClientAuthMode();
const { data: session, isPending: isSessionPending } = useSession();
const userId = session?.user?.id ?? null;
const organizationId = getActiveOrganizationId(session);
const previousUserIdRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!isHostedMode) {
if (!isHostedMode || isSessionPending) {
return;
}
initPostHog();
}, [isHostedMode]);
if (userId) {
identifyAnalyticsUser({ userId, organizationId });
previousUserIdRef.current = userId;
} else if (previousUserIdRef.current) {
previousUserIdRef.current = null;
resetAnalyticsUser();
}
}, [isHostedMode, isSessionPending, organizationId, userId]);
return null;
}

View File

@ -9,6 +9,7 @@ import {
getFormError,
useAuthPageState,
} from "@/client/features/auth/AuthPage";
import { captureClientEvent } from "@/client/lib/posthog";
import { authClient } from "@/lib/auth-client";
import { getSignInSearch } from "@/lib/auth-redirect";
import { z } from "zod";
@ -25,9 +26,7 @@ export const Route = createFileRoute("/_auth/sign-in")({
function SignInPage() {
const search = Route.useSearch();
const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState(
search.redirect,
);
const { redirectTo, isHostedMode } = useAuthPageState(search.redirect);
const [verificationEmail, setVerificationEmail] = useState<string | null>(
null,
);
@ -44,6 +43,9 @@ function SignInPage() {
onSubmit: async ({ formApi, value }) => {
try {
const email = value.email.trim();
captureClientEvent("auth:sign_in_submit", {
redirect_to: redirectTo,
});
setVerificationEmail(null);
const result = await authClient.signIn.email({
@ -53,10 +55,16 @@ function SignInPage() {
});
if (!result.error) {
captureClientEvent("auth:sign_in_success", {
redirect_to: redirectTo,
});
return;
}
if (result.error.status === 403) {
captureClientEvent("auth:sign_in_block_unverified", {
redirect_to: redirectTo,
});
setVerificationEmail(email);
formApi.setErrorMap({
onSubmit: {
@ -105,6 +113,7 @@ function SignInPage() {
return;
}
captureClientEvent("auth:verification_resend");
toast.success("A new email is on the way.");
} catch {
toast.error(
@ -159,7 +168,7 @@ function SignInPage() {
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="email"
disabled={!isHostedMode || isSessionPending}
disabled={!isHostedMode}
required
/>
{error ? (
@ -183,7 +192,7 @@ function SignInPage() {
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="current-password"
disabled={!isHostedMode || isSessionPending}
disabled={!isHostedMode}
required
/>
{error ? (
@ -232,7 +241,7 @@ function SignInPage() {
) : null}
<button
className="btn btn-soft w-full"
disabled={!isHostedMode || isSessionPending || isSubmitting}
disabled={!isHostedMode || isSubmitting}
>
{isSubmitting ? "Signing in..." : "Sign in"}
</button>

View File

@ -7,6 +7,7 @@ import {
getFormError,
useAuthPageState,
} from "@/client/features/auth/AuthPage";
import { captureClientEvent } from "@/client/lib/posthog";
import { authClient } from "@/lib/auth-client";
import { getSignInSearch } from "@/lib/auth-redirect";
import {
@ -44,9 +45,7 @@ export const Route = createFileRoute("/_auth/sign-up")({
function SignUpPage() {
const search = Route.useSearch();
const navigate = useNavigate();
const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState(
search.redirect,
);
const { redirectTo, isHostedMode } = useAuthPageState(search.redirect);
const form = useForm({
defaultValues: {
@ -61,6 +60,9 @@ function SignUpPage() {
onSubmit: async ({ formApi, value }) => {
try {
const email = value.email.trim();
captureClientEvent("auth:sign_up_submit", {
redirect_to: redirectTo,
});
const resolvedName =
value.name.trim() || email.split("@")[0] || "OpenSEO User";
const result = await authClient.signUp.email({
@ -85,6 +87,9 @@ function SignUpPage() {
return;
}
captureClientEvent("auth:sign_up_success", {
redirect_to: redirectTo,
});
void navigate({
to: "/verify-email",
search: { email, ...getSignInSearch(redirectTo) },
@ -162,7 +167,7 @@ function SignUpPage() {
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="name"
disabled={!isHostedMode || isSessionPending}
disabled={!isHostedMode}
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
@ -185,7 +190,7 @@ function SignUpPage() {
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="email"
disabled={!isHostedMode || isSessionPending}
disabled={!isHostedMode}
required
/>
{error ? (
@ -209,7 +214,7 @@ function SignUpPage() {
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="new-password"
disabled={!isHostedMode || isSessionPending}
disabled={!isHostedMode}
required
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
@ -235,7 +240,7 @@ function SignUpPage() {
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="new-password"
disabled={!isHostedMode || isSessionPending}
disabled={!isHostedMode}
required
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
@ -263,7 +268,7 @@ function SignUpPage() {
) : null}
<button
className="btn btn-soft w-full"
disabled={!isHostedMode || isSessionPending || isSubmitting}
disabled={!isHostedMode || isSubmitting}
>
{isSubmitting ? "Creating account..." : "Create account"}
</button>

View File

@ -3,8 +3,8 @@ import { AutumnProvider, useCustomer } from "autumn-js/react";
import { useEffect, useState } from "react";
import { User } from "lucide-react";
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { authClient, useSession } from "@/lib/auth-client";
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
import { captureClientEvent } from "@/client/lib/posthog";
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { getSubscribeRouteState } from "@/client/features/billing/route-state";
import {
@ -96,6 +96,7 @@ function SubscribePageContent() {
setIsAttaching(true);
try {
captureClientEvent("billing:checkout_start");
await customerQuery.attach({
planId: AUTUMN_PAID_PLAN_ID,
redirectMode: "always",
@ -168,16 +169,7 @@ function SubscribePageContent() {
function SubscribePageAccountMenu({ email }: { email: string | undefined }) {
if (!email) return null;
const handleSignOut = () => {
const signInHref = getSignInHrefForLocation(window.location);
void authClient.signOut({
fetchOptions: {
onSuccess: () => {
window.location.assign(signInHref);
},
},
});
};
const handleSignOut = () => signOutAndRedirect();
return (
<div className="fixed top-4 right-4">

View File

@ -9,6 +9,7 @@ import {
import { Trash2, Download, Search, Loader2, AlertCircle } from "lucide-react";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
export const Route = createFileRoute("/_project/p/$projectId/saved")({
component: SavedKeywordsPage,
@ -33,6 +34,7 @@ function SavedKeywordsPage() {
void queryClient.invalidateQueries({
queryKey: ["savedKeywords", projectId],
});
captureClientEvent("saved_keywords:remove");
toast.success("Keyword removed");
},
onError: (error) => {
@ -78,6 +80,10 @@ function SavedKeywordsPage() {
]);
const csv = buildCsv(headers, csvRows);
downloadCsv("saved-keywords.csv", csv);
captureClientEvent("data:export", {
source_feature: "saved_keywords",
result_count: savedKeywords.length,
});
};
return (

View File

@ -6,11 +6,16 @@ import {
AuthPageShell,
authRedirectSearchSchema,
} from "@/client/features/auth/AuthPage";
import { captureClientEvent } from "@/client/lib/posthog";
import { authClient, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { getSignInSearch, normalizeAuthRedirect } from "@/lib/auth-redirect";
import { z } from "zod";
const verificationIssueSchema = z
.enum(["invalid_token", "token_expired", "user_not_found", "unknown"])
.catch("unknown");
const verifyEmailSearchSchema = authRedirectSearchSchema.extend({
error: z.string().optional(),
email: z.string().optional(),
@ -98,6 +103,9 @@ function VerifyEmailPage() {
const isHostedMode = isHostedClientAuthMode();
const { data: session, isPending } = useSession();
const errorMessage = getVerificationErrorMessage(search.error);
const verificationIssueType = search.error
? verificationIssueSchema.parse(search.error)
: null;
const email = search.email;
const isWaiting = !errorMessage && !session?.user?.emailVerified && !!email;
const [isResending, setIsResending] = useState(false);
@ -116,6 +124,10 @@ function VerifyEmailPage() {
return;
}
captureClientEvent("auth:verification_success", {
redirect_to: redirectTo,
});
// Full page reload instead of client-side navigation: the auth→app
// transition needs a clean server-side load so that all server function
// handlers are freshly registered (client-side nav during Vite HMR can
@ -124,6 +136,16 @@ function VerifyEmailPage() {
window.location.replace(redirectTo);
}, [isVerified, redirectTo]);
useEffect(() => {
if (!verificationIssueType) {
return;
}
captureClientEvent("auth:verification_issue", {
issue_type: verificationIssueType,
});
}, [verificationIssueType]);
async function handleResend() {
if (!email) return;
setIsResending(true);
@ -139,6 +161,7 @@ function VerifyEmailPage() {
toast.error(result.error.message || "We couldn't send another email.");
return;
}
captureClientEvent("auth:verification_resend");
toast.success("A new email is on the way.");
} catch {
toast.error(

View File

@ -52,6 +52,7 @@ describe("subscription billing", () => {
await expect(
requireManagedServiceAccess({
organizationId: "org_123",
userId: "user_123",
userEmail: "alice@example.com",
}),
).resolves.toBeUndefined();
@ -68,6 +69,7 @@ describe("subscription billing", () => {
await expect(
requireManagedServiceAccess({
organizationId: "org_123",
userId: "user_123",
userEmail: "alice@example.com",
}),
).rejects.toMatchObject({ code: "PAYMENT_REQUIRED" });
@ -78,6 +80,7 @@ describe("subscription billing", () => {
await getOrCreateOrganizationCustomer({
organizationId: "org_123",
userId: "user_123",
userEmail: "alice@example.com",
});

View File

@ -6,8 +6,10 @@ import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
export type BillingCustomerContext = Pick<
EnsuredUserContext,
"organizationId" | "userEmail"
>;
"organizationId" | "userEmail" | "userId"
> & {
projectId?: string;
};
export async function getOrCreateOrganizationCustomer(
context: BillingCustomerContext,

View File

@ -36,6 +36,7 @@ import { createBacklinksService } from "./BacklinksService";
const billingCustomer = {
organizationId: "org_123",
userId: "user_123",
userEmail: "team@example.com",
};
@ -227,6 +228,7 @@ it("keeps cache entries isolated per organization", async () => {
await service.profileOverview(input, billingCustomer);
await service.profileOverview(input, {
organizationId: "org_456",
userId: "user_456",
userEmail: "other@example.com",
});

View File

@ -19,6 +19,10 @@ const { checkMock, trackMock, getOrCreateMock, isHostedServerAuthModeMock } =
isHostedServerAuthModeMock: vi.fn(),
}));
vi.mock("cloudflare:workers", () => ({
waitUntil: vi.fn(),
}));
vi.mock("@/server/billing/autumn", () => ({
autumn: {
check: checkMock,
@ -34,6 +38,10 @@ vi.mock("@/server/lib/runtime-env", () => ({
isHostedServerAuthMode: isHostedServerAuthModeMock,
}));
vi.mock("@/server/lib/posthog", () => ({
captureServerEvent: vi.fn(),
}));
vi.mock("@/server/lib/dataforseo", () => ({
fetchKeywordIdeasRaw: vi.fn(),
fetchKeywordSuggestionsRaw: vi.fn(),
@ -55,11 +63,15 @@ vi.mock("@/server/lib/dataforseoBacklinks", () => ({
fetchReferringDomainsRaw: vi.fn(),
}));
import { createDataforseoClient } from "./dataforseoClient";
import {
createDataforseoClient,
mapDataforseoPathToCreditFeature,
} from "./dataforseoClient";
import { fetchBacklinksSummaryRaw } from "./dataforseoBacklinks";
const billingCustomer = {
organizationId: "org_123",
userId: "user_123",
userEmail: "alice@example.com",
};
@ -234,3 +246,86 @@ describe("meterDataforseoCall with split balances", () => {
);
});
});
describe("mapDataforseoPathToCreditFeature", () => {
it("maps real keyword research paths", () => {
expect(
mapDataforseoPathToCreditFeature([
"v3",
"dataforseo_labs",
"google",
"related_keywords",
"live",
]),
).toBe("keyword_research");
expect(
mapDataforseoPathToCreditFeature([
"v3",
"dataforseo_labs",
"google",
"keyword_suggestions",
"live",
]),
).toBe("keyword_research");
});
it("maps real serp paths", () => {
expect(
mapDataforseoPathToCreditFeature([
"v3",
"serp",
"google",
"organic",
"live",
"regular",
]),
).toBe("keyword_research");
});
it("maps real domain paths", () => {
expect(
mapDataforseoPathToCreditFeature([
"v3",
"dataforseo_labs",
"google",
"domain_rank_overview",
"live",
]),
).toBe("domain_overview");
expect(
mapDataforseoPathToCreditFeature([
"v3",
"dataforseo_labs",
"google",
"ranked_keywords",
"live",
]),
).toBe("domain_overview");
});
it("maps real backlinks paths", () => {
expect(
mapDataforseoPathToCreditFeature(["v3", "backlinks", "summary", "live"]),
).toBe("backlinks");
expect(
mapDataforseoPathToCreditFeature([
"v3",
"backlinks",
"referring_domains",
"live",
]),
).toBe("backlinks");
});
it("maps real lighthouse/on_page paths to site_audit", () => {
expect(
mapDataforseoPathToCreditFeature([
"v3",
"on_page",
"lighthouse",
"live",
"json",
]),
).toBe("site_audit");
});
});

View File

@ -36,8 +36,44 @@ import {
type DataforseoApiCallCost,
} from "@/server/lib/dataforseoCost";
import { AppError } from "@/server/lib/errors";
import { captureServerEvent } from "@/server/lib/posthog";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
type CreditFeature =
| "keyword_research"
| "domain_overview"
| "backlinks"
| "site_audit";
/**
* Maps a DataForSEO API response path (e.g. ["v3", "dataforseo_labs", "google", "related_keywords", "live"])
* to a product feature for analytics. path[1] is the API module; for dataforseo_labs,
* path[3] distinguishes keyword vs domain endpoints.
*/
export function mapDataforseoPathToCreditFeature(
path: string[],
): CreditFeature {
const module = path[1];
switch (module) {
case "on_page":
return "site_audit";
case "backlinks":
return "backlinks";
case "serp":
return "keyword_research";
case "dataforseo_labs": {
const endpoint = path[3] ?? "";
if (endpoint.startsWith("domain_") || endpoint === "ranked_keywords") {
return "domain_overview";
}
return "keyword_research";
}
default:
return "site_audit";
}
}
export function createDataforseoClient(customer: BillingCustomerContext) {
return {
backlinks: {
@ -194,6 +230,7 @@ async function meterDataforseoCall<T>(
const result = await execute();
await trackDataforseoCost({
customer,
customerId: billingCustomer.id,
billing: result.billing,
monthlyRemaining,
@ -233,6 +270,7 @@ async function assertSeoDataBalanceAvailable(args: {
}
async function trackDataforseoCost(args: {
customer: BillingCustomerContext;
customerId: string;
billing: DataforseoApiCallCost;
monthlyRemaining: number;
@ -277,6 +315,22 @@ async function trackDataforseoCost(args: {
},
});
}
if (totalCostCredits > 0) {
await captureServerEvent({
distinctId: args.customer.userId,
event: "usage:credits_consume",
organizationId: args.customer.organizationId,
properties: {
project_id: args.customer.projectId,
credit_feature: mapDataforseoPathToCreditFeature(args.billing.path),
monthly_credits: monthlyDeduct,
topup_credits: topupDeduct,
total_credits: totalCostCredits,
cost_usd: totalCostUsd,
},
});
}
}
export type { LabsKeywordDataItem, SerpLiveItem };

View File

@ -2,6 +2,20 @@ import { env } from "cloudflare:workers";
import { PostHog } from "posthog-node";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
/** Returns a one-shot PostHog client, or null if the key is missing. Caller must shut down after use.
* A new instance per call is fine this runs on Cloudflare Workers where construction cost is negligible. */
function getServerPostHogClient(): PostHog | null {
const apiKey = env.POSTHOG_PUBLIC_KEY?.trim();
const host = env.POSTHOG_HOST?.trim();
if (!apiKey || !host) return null;
return new PostHog(apiKey, {
host,
flushAt: 1,
flushInterval: 0,
});
}
export async function captureServerError(
error: unknown,
properties: Record<string, string | null | undefined> = {},
@ -10,17 +24,8 @@ export async function captureServerError(
return;
}
const apiKey = env.POSTHOG_PUBLIC_KEY?.trim();
if (!apiKey) {
return;
}
const client = new PostHog(apiKey, {
host: env.POSTHOG_HOST?.trim() || "https://us.i.posthog.com",
flushAt: 1,
flushInterval: 0,
});
const client = getServerPostHogClient();
if (!client) return;
try {
await client.captureExceptionImmediate(error, undefined, {
@ -30,6 +35,35 @@ export async function captureServerError(
} catch (posthogError) {
console.error("posthog server capture failed", posthogError);
} finally {
await client.shutdown();
await client.shutdown().catch(() => {});
}
}
export async function captureServerEvent(args: {
distinctId: string;
event: string;
properties?: Record<string, unknown>;
organizationId: string;
}) {
if (!(await isHostedServerAuthMode())) {
return;
}
const client = getServerPostHogClient();
if (!client) return;
try {
client.capture({
distinctId: args.distinctId,
event: args.event,
properties: args.properties,
groups: {
organization: args.organizationId,
},
});
} catch (posthogError) {
console.error("posthog server capture failed", posthogError);
} finally {
await client.shutdown().catch(() => {});
}
}

View File

@ -12,6 +12,7 @@ import {
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import type { AuditConfig } from "@/server/lib/audit/types";
import { captureServerEvent } from "@/server/lib/posthog";
import { runAuditPhases } from "@/server/workflows/siteAuditWorkflowPhases";
interface AuditParams {
@ -53,6 +54,24 @@ export class SiteAuditWorkflow extends WorkflowEntrypoint<Env, AuditParams> {
console.error(`Audit ${auditId} failed:`, error);
await step.do("mark-failed", async () => {
await AuditRepository.failAudit(auditId, event.instanceId);
const latestAudit = await AuditRepository.getAuditForWorkflow(
auditId,
event.instanceId,
);
await captureServerEvent({
distinctId: billingCustomer.userId,
event: "site_audit:complete",
organizationId: billingCustomer.organizationId,
properties: {
project_id: projectId,
status: "failed",
pages_crawled: latestAudit?.pagesCrawled,
pages_total: latestAudit?.pagesTotal,
run_lighthouse: config.lighthouseStrategy !== "none",
},
});
});
throw error;
}

View File

@ -13,6 +13,7 @@ import type {
LighthouseResult,
StepPageResult,
} from "@/server/lib/audit/types";
import { captureServerEvent } from "@/server/lib/posthog";
import { runCrawlPhase } from "@/server/workflows/siteAuditWorkflowCrawl";
const LIGHTHOUSE_URL_BATCH_SIZE = 10;
@ -83,13 +84,16 @@ export async function runAuditPhases(
config,
allPages,
});
await finalizeAudit(
await finalizeAudit({
step,
auditId,
workflowInstanceId,
billingCustomer,
projectId,
config,
allPages,
lighthouseResults,
);
});
}
async function runDiscoveryPhase(
@ -249,13 +253,27 @@ async function runLighthouseBatch(params: {
});
}
async function finalizeAudit(
step: WorkflowStep,
auditId: string,
workflowInstanceId: string,
allPages: StepPageResult[],
lighthouseResults: LighthouseResult[],
) {
async function finalizeAudit(args: {
step: WorkflowStep;
auditId: string;
workflowInstanceId: string;
billingCustomer: BillingCustomerContext;
projectId: string;
config: AuditConfig;
allPages: StepPageResult[];
lighthouseResults: LighthouseResult[];
}) {
const {
step,
auditId,
workflowInstanceId,
billingCustomer,
projectId,
config,
allPages,
lighthouseResults,
} = args;
await step.do("finalize", async () => {
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
currentPhase: "finalizing",
@ -269,6 +287,18 @@ async function finalizeAudit(
pagesCrawled: allPages.length,
pagesTotal: allPages.length,
});
await captureServerEvent({
distinctId: billingCustomer.userId,
event: "site_audit:complete",
organizationId: billingCustomer.organizationId,
properties: {
project_id: projectId,
status: "completed",
pages_crawled: allPages.length,
pages_total: allPages.length,
run_lighthouse: config.lighthouseStrategy !== "none",
},
});
await AuditProgressKV.clear(auditId);
});
}

View File

@ -1,5 +1,7 @@
import { createServerFn } from "@tanstack/react-start";
import { waitUntil } from "cloudflare:workers";
import { AuditService } from "@/server/features/audit/services/AuditService";
import { captureServerEvent } from "@/server/lib/posthog";
import { requireProjectContext } from "@/serverFunctions/middleware";
import {
deleteAuditSchema,
@ -14,51 +16,63 @@ export const startAudit = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => startAuditSchema.parse(data))
.handler(async ({ data, context }) => {
return AuditService.startAudit({
const result = await AuditService.startAudit({
actorUserId: context.userId,
billingCustomer: {
organizationId: context.organizationId,
userEmail: context.userEmail,
},
projectId: context.project.id,
billingCustomer: context,
projectId: context.projectId,
startUrl: data.startUrl,
maxPages: data.maxPages,
lighthouseStrategy: data.lighthouseStrategy,
});
waitUntil(
captureServerEvent({
distinctId: context.userId,
event: "site_audit:start",
organizationId: context.organizationId,
properties: {
project_id: context.projectId,
max_pages: data.maxPages ?? 50,
run_lighthouse: data.lighthouseStrategy !== "none",
},
}),
);
return result;
});
export const getAuditStatus = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => getAuditStatusSchema.parse(data))
.handler(async ({ data, context }) => {
return AuditService.getStatus(data.auditId, context.project.id);
return AuditService.getStatus(data.auditId, context.projectId);
});
export const getAuditResults = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => getAuditResultsSchema.parse(data))
.handler(async ({ data, context }) => {
return AuditService.getResults(data.auditId, context.project.id);
return AuditService.getResults(data.auditId, context.projectId);
});
export const getAuditHistory = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => getAuditHistorySchema.parse(data))
.handler(async ({ context }) => {
return AuditService.getHistory(context.project.id);
return AuditService.getHistory(context.projectId);
});
export const getCrawlProgress = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => getCrawlProgressSchema.parse(data))
.handler(async ({ data, context }) => {
return AuditService.getCrawlProgress(data.auditId, context.project.id);
return AuditService.getCrawlProgress(data.auditId, context.projectId);
});
export const deleteAudit = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => deleteAuditSchema.parse(data))
.handler(async ({ data, context }) => {
await AuditService.remove(data.auditId, context.project.id);
await AuditService.remove(data.auditId, context.projectId);
return { success: true };
});

View File

@ -19,10 +19,7 @@ export const getBacklinksOverview = createServerFn({
target: data.target,
scope: data.scope,
};
const profile = await BacklinksService.profileOverview(input, {
organizationId: context.organizationId,
userEmail: context.userEmail,
});
const profile = await BacklinksService.profileOverview(input, context);
return profile.overview;
} catch (error) {
if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") {
@ -47,10 +44,10 @@ export const getBacklinksReferringDomains = createServerFn({
target: data.target,
scope: data.scope,
};
const profile = await BacklinksService.profileReferringDomains(input, {
organizationId: context.organizationId,
userEmail: context.userEmail,
});
const profile = await BacklinksService.profileReferringDomains(
input,
context,
);
return profile.rows;
} catch (error) {
await updateBacklinksAccessStatusOnError(error);
@ -69,10 +66,7 @@ export const getBacklinksTopPages = createServerFn({
target: data.target,
scope: data.scope,
};
const profile = await BacklinksService.profileTopPages(input, {
organizationId: context.organizationId,
userEmail: context.userEmail,
});
const profile = await BacklinksService.profileTopPages(input, context);
return profile.rows;
} catch (error) {
await updateBacklinksAccessStatusOnError(error);

View File

@ -38,10 +38,7 @@ export const testBacklinksAccess = createServerFn({
}
const checkedAt = new Date().toISOString();
const dataforseo = createDataforseoClient({
organizationId: context.organizationId,
userEmail: context.userEmail,
});
const dataforseo = createDataforseoClient(context);
try {
await dataforseo.backlinks.summary({

View File

@ -10,11 +10,8 @@ export const getDomainOverview = createServerFn({ method: "POST" })
DomainService.getOverview(
{
...data,
projectId: context.project.id,
},
{
organizationId: context.organizationId,
userEmail: context.userEmail,
projectId: context.projectId,
},
context,
),
);

View File

@ -16,12 +16,9 @@ export const researchKeywords = createServerFn({ method: "POST" })
return KeywordResearchService.research(
{
...data,
projectId: context.project.id,
},
{
organizationId: context.organizationId,
userEmail: context.userEmail,
projectId: context.projectId,
},
context,
);
});
@ -31,7 +28,7 @@ export const saveKeywords = createServerFn({ method: "POST" })
.handler(async ({ data, context }) => {
return KeywordResearchService.saveKeywords({
...data,
projectId: context.project.id,
projectId: context.projectId,
});
});
@ -41,7 +38,7 @@ export const getSavedKeywords = createServerFn({ method: "POST" })
.handler(async ({ data, context }) => {
return KeywordResearchService.getSavedKeywords({
...data,
projectId: context.project.id,
projectId: context.projectId,
});
});
@ -51,7 +48,7 @@ export const removeSavedKeyword = createServerFn({
.middleware(requireProjectContext)
.inputValidator((data: unknown) => removeSavedKeywordSchema.parse(data))
.handler(async ({ data, context }) => {
return KeywordResearchService.removeSavedKeyword(context.project.id, data);
return KeywordResearchService.removeSavedKeyword(context.projectId, data);
});
export const getSerpAnalysis = createServerFn({ method: "POST" })
@ -61,11 +58,8 @@ export const getSerpAnalysis = createServerFn({ method: "POST" })
KeywordResearchService.getSerpAnalysis(
{
...data,
projectId: context.project.id,
},
{
organizationId: context.organizationId,
userEmail: context.userEmail,
projectId: context.projectId,
},
context,
),
);

View File

@ -48,7 +48,7 @@ export const getAuditLighthouseIssues = createServerFn({ method: "POST" })
.inputValidator((data: unknown) => lighthouseAuditIssueSchema.parse(data))
.handler(async ({ data, context }) => {
const lighthouse = await getAuditLighthouseData({
projectId: context.project.id,
projectId: context.projectId,
resultId: data.resultId,
});
@ -71,7 +71,7 @@ export const exportAuditLighthouseIssues = createServerFn({ method: "POST" })
.inputValidator((data: unknown) => lighthouseAuditExportSchema.parse(data))
.handler(async ({ data, context }) => {
const lighthouse = await getAuditLighthouseData({
projectId: context.project.id,
projectId: context.projectId,
resultId: data.resultId,
});

View File

@ -1,40 +1,27 @@
import { createMiddleware } from "@tanstack/react-start";
import { z } from "zod";
import { AppError } from "@/server/lib/errors";
import { errorHandlingMiddleware } from "@/middleware/errorHandling";
import type { EnsuredUserContext } from "@/middleware/ensure-user/types";
import { ensureUserMiddleware } from "@/middleware/ensureUser";
import { requireManagedServiceAccess } from "@/server/billing/subscription";
type AuthenticatedServerFunctionContext = EnsuredUserContext;
const ensuredUserContextSchema: z.ZodType<EnsuredUserContext> = z.object({
userId: z.string(),
userEmail: z.string(),
organizationId: z.string(),
project: z.any().optional(),
});
function getAuthenticatedContext(
context: unknown,
): AuthenticatedServerFunctionContext {
if (!isAuthenticatedServerFunctionContext(context)) {
function getAuthenticatedContext(context: unknown): EnsuredUserContext {
const result = ensuredUserContextSchema.safeParse(context);
if (!result.success) {
throw new AppError(
"INTERNAL_ERROR",
"Authenticated server function context missing",
);
}
return context;
}
function isAuthenticatedServerFunctionContext(
context: unknown,
): context is AuthenticatedServerFunctionContext {
if (!context || typeof context !== "object") {
return false;
}
return (
"userId" in context &&
typeof context.userId === "string" &&
"userEmail" in context &&
typeof context.userEmail === "string" &&
"organizationId" in context &&
typeof context.organizationId === "string"
);
return result.data;
}
export const globalServerFunctionMiddleware = [
@ -70,6 +57,7 @@ export const requireProjectContext = [
context: {
...authenticatedContext,
project: authenticatedContext.project,
projectId: authenticatedContext.project.id,
},
});
}),