feat(analytics): proxy PostHog ingestion through public-api on Vercel

- Add Vercel rewrites to proxy `/ingest/*` and `/ingest/static/*` to PostHog's US endpoints
- Update native `.env.example` to point `EXPO_PUBLIC_POSTHOG_HOST` at the self-hosted proxy URL
This commit is contained in:
2026-03-21 15:39:56 -04:00
parent ef90e57f19
commit 37c35488fd
12 changed files with 80 additions and 22 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
EXPO_PUBLIC_POSTHOG_KEY=
EXPO_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
EXPO_PUBLIC_POSTHOG_HOST=https://public-api.sofa.watch/ingest
+1
View File
@@ -10,6 +10,7 @@ npm-debug.*
*.orig.*
*.tar.gz
*.ipa
*.apk
web-build/
# macOS
+5
View File
@@ -7,6 +7,11 @@
"production": {
"autoIncrement": true
},
"preview": {
"android": {
"buildType": "apk"
}
},
"development": {
"developmentClient": true,
"ios": {
@@ -53,7 +53,7 @@ import { setPersistedLocale } from "@/lib/i18n";
import { orpc } from "@/lib/orpc";
import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client";
import { authClient, getServerUrl } from "@/lib/server";
import { authClient, getServerUrl, requestServerChange } from "@/lib/server";
import { toast } from "@/lib/toast";
import { activateLocale, isLocaleRTL, type SupportedLocale } from "@sofa/i18n";
import { LOCALE_INFO } from "@sofa/i18n/locales";
@@ -372,9 +372,9 @@ export default function SettingsScreen() {
text: t`Continue`,
style: "destructive",
onPress: async () => {
requestServerChange();
await authClient.signOut();
queryClient.clear();
push("/(auth)/server-url");
},
},
]);
@@ -21,7 +21,7 @@ export function OfflineBanner() {
const handleState = (state: Network.NetworkState) => {
if (!mounted) return;
const offline = !state.isConnected || !state.isInternetReachable;
const offline = !state.isConnected || state.isInternetReachable === false;
setIsOffline(offline);
if (offline && wasOnline.current) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
@@ -20,7 +20,7 @@ export function OfflineBanner() {
const handleState = (state: Network.NetworkState) => {
if (!mounted) return;
const offline = !state.isConnected || !state.isInternetReachable;
const offline = !state.isConnected || state.isInternetReachable === false;
setIsOffline(offline);
if (offline && wasOnline.current) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
@@ -10,10 +10,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
import { queryClient } from "@/lib/query-client";
import { authClient, useServerReachability } from "@/lib/server";
import { authClient, hasStoredServerUrl, useServerReachability } from "@/lib/server";
import * as Haptics from "@/utils/haptics";
export function ServerUnreachableBanner() {
const hasServerUrl = !!process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl();
const { isReachable } = useServerReachability();
const insets = useSafeAreaInsets();
const wasReachable = useRef(true);
@@ -26,7 +27,7 @@ export function ServerUnreachableBanner() {
const handleState = (state: Network.NetworkState) => {
if (!mounted) return;
setIsDeviceOnline(!!state.isConnected && !!state.isInternetReachable);
setIsDeviceOnline(!!state.isConnected && state.isInternetReachable !== false);
};
Network.getNetworkStateAsync().then(handleState);
@@ -39,11 +40,11 @@ export function ServerUnreachableBanner() {
}, []);
useEffect(() => {
if (!isReachable && wasReachable.current) {
if (hasServerUrl && !isReachable && wasReachable.current) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
}
wasReachable.current = isReachable;
}, [isReachable]);
}, [hasServerUrl, isReachable]);
const handleRetry = () => {
const refetchSession = authClient.$store.atoms.session.get().refetch;
@@ -55,8 +56,9 @@ export function ServerUnreachableBanner() {
]);
};
// Only show when device has internet but server is unreachable
if (isReachable || !isDeviceOnline) return null;
// Don't show on fresh install (no server configured yet) or when the device
// itself is offline (OfflineBanner handles that) or when everything is fine.
if (!hasServerUrl || isReachable || !isDeviceOnline) return null;
const useGlass = isLiquidGlassAvailable();
@@ -9,10 +9,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
import { queryClient } from "@/lib/query-client";
import { authClient, useServerReachability } from "@/lib/server";
import { authClient, hasStoredServerUrl, useServerReachability } from "@/lib/server";
import * as Haptics from "@/utils/haptics";
export function ServerUnreachableBanner() {
const hasServerUrl = !!process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl();
const { isReachable } = useServerReachability();
const insets = useSafeAreaInsets();
const wasReachable = useRef(true);
@@ -25,7 +26,7 @@ export function ServerUnreachableBanner() {
const handleState = (state: Network.NetworkState) => {
if (!mounted) return;
setIsDeviceOnline(!!state.isConnected && !!state.isInternetReachable);
setIsDeviceOnline(!!state.isConnected && state.isInternetReachable !== false);
};
Network.getNetworkStateAsync().then(handleState);
@@ -38,11 +39,11 @@ export function ServerUnreachableBanner() {
}, []);
useEffect(() => {
if (!isReachable && wasReachable.current) {
if (hasServerUrl && !isReachable && wasReachable.current) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
}
wasReachable.current = isReachable;
}, [isReachable]);
}, [hasServerUrl, isReachable]);
const handleRetry = () => {
const refetchSession = authClient.$store.atoms.session.get().refetch;
@@ -54,8 +55,9 @@ export function ServerUnreachableBanner() {
]);
};
// Only show when device has internet but server is unreachable
if (isReachable || !isDeviceOnline) return null;
// Don't show on fresh install (no server configured yet) or when the device
// itself is offline (OfflineBanner handles that) or when everything is fine.
if (!hasServerUrl || isReachable || !isDeviceOnline) return null;
return (
<Animated.View
@@ -1,3 +1,4 @@
import { msg } from "@lingui/core/macro";
import { useRouter } from "expo-router";
import { useEffect, useRef, useState } from "react";
@@ -6,6 +7,7 @@ import { queryClient } from "@/lib/query-client";
import {
authClient,
clearCachedSessionSeeded,
consumeServerChangeRequest,
ensureInstanceId,
getCurrentInstanceId,
hasStoredServerUrl,
@@ -16,6 +18,7 @@ import {
wasCachedSessionSeeded,
} from "@/lib/server";
import { toast } from "@/lib/toast";
import { i18n } from "@sofa/i18n";
/**
* Manages the full server connection lifecycle:
@@ -100,16 +103,17 @@ export function useServerConnection() {
const { replace } = useRouter();
// Navigate to login when session is lost. Stack.Protected handles screen
// Navigate to auth when session is lost. Stack.Protected handles screen
// availability, but enableFreeze can prevent the navigator from
// transitioning on its own.
useEffect(() => {
if (prevSession.current && !session) {
replace("/(auth)/login");
const changingServer = consumeServerChangeRequest();
replace(changingServer ? "/(auth)/server-url" : "/(auth)/login");
if (hadOptimisticSession.current) {
toast.info("Session expired", {
description: "Please sign in again.",
toast.info(i18n._(msg`Session expired`), {
description: i18n._(msg`Please sign in again.`),
});
clearCachedSessionSeeded();
}
+26
View File
@@ -412,6 +412,28 @@ export function clearCachedSessionSeeded(): void {
// serverManager — compound operations for server-url screen
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Server-change flag
// ---------------------------------------------------------------------------
// Signals that the next session-loss redirect should go to the server-url
// screen instead of login (set by the "Change Server" flow in settings).
let _serverChangeRequested = false;
export function requestServerChange(): void {
_serverChangeRequested = true;
}
export function consumeServerChangeRequest(): boolean {
const was = _serverChangeRequested;
_serverChangeRequested = false;
return was;
}
// ---------------------------------------------------------------------------
// serverManager — compound operations for server-url screen
// ---------------------------------------------------------------------------
export const serverManager = {
validateServerUrl,
normalizeUrl,
@@ -424,6 +446,10 @@ export const serverManager = {
connectToServer(url: string, instanceId: string): void {
registerServer(url, instanceId);
setServerUrlInternal(url);
// Validation just succeeded, so mark the server as reachable before
// rebuilding. This prevents a banner flash between the monitor starting
// (once hasServerUrl becomes true) and the first successful fetch.
setReachable(true);
authClient = buildAuthClient();
for (const listener of serverUrlListeners) listener();
},
+11 -1
View File
@@ -1,3 +1,13 @@
{
"bunVersion": "1.x"
"bunVersion": "1.x",
"rewrites": [
{
"source": "/ingest/static/:path(.*)",
"destination": "https://us-assets.i.posthog.com/static/:path"
},
{
"source": "/ingest/:path(.*)",
"destination": "https://us.i.posthog.com/:path"
}
]
}
+8
View File
@@ -1650,6 +1650,10 @@ msgstr ""
msgid "Play trailer"
msgstr ""
#: apps/native/src/hooks/use-server-connection.ts
msgid "Please sign in again."
msgstr "Please sign in again."
#: apps/native/src/app/(tabs)/(explore)/index.tsx
#: apps/web/src/routes/_app/explore.tsx
msgid "Popular Movies"
@@ -2091,6 +2095,10 @@ msgstr ""
msgid "Server URL"
msgstr ""
#: apps/native/src/hooks/use-server-connection.ts
msgid "Session expired"
msgstr "Session expired"
#: apps/native/src/app/(tabs)/(settings)/index.tsx
msgid "Set password"
msgstr ""