feat(webapp): proxy PostHog through a same-origin /ph path (#4183)

## Summary

posthog-js sent product analytics to PostHog Cloud directly from the
browser. This points `api_host` at a same-origin `/ph` path that
forwards to PostHog Cloud EU server-side, following PostHog's standard
first-party reverse-proxy setup.

## How it works

A resource route forwards each request server-side, splitting by path:
`/ph/static/*` and `/ph/array/*` go to the asset host, everything else
(analytics events, feature flags) goes to the ingest host. It rewrites
the `Host` header, strips the `/ph` prefix, and streams the response
back. Only PostHog's own cookies are forwarded, so the app session
cookie stays first-party. Upstream hosts default to PostHog Cloud EU,
overridable via `POSTHOG_INGEST_HOST` / `POSTHOG_ASSETS_HOST`.

It also sets `cross_subdomain_cookie` so a single PostHog session is
shared across the marketing site and app.

Verified locally: static assets return 200 from the EU asset host, and
analytics events return 200 through the ingest host.
This commit is contained in:
DKP
2026-07-08 11:35:04 +01:00
committed by GitHub
parent fbd86b6ee9
commit 00ee0751ec
7 changed files with 135 additions and 8 deletions
+6
View File
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---
Serve PostHog analytics from a same-origin `/ph` path that reverse-proxies to PostHog Cloud EU, following PostHog's first-party reverse-proxy guidance.
+9
View File
@@ -196,6 +196,15 @@ const EnvironmentSchema = z
SERVICE_NAME: z.string().default("trigger.dev webapp"),
SENTRY_DSN: z.string().optional(),
POSTHOG_PROJECT_KEY: z.string().default("phc_LFH7kJiGhdIlnO22hTAKgHpaKhpM8gkzWAFvHmf5vfS"),
// Upstream hosts the /ph reverse proxy forwards to (defaults: PostHog Cloud
// EU). The client points api_host at the same-origin /ph path; the proxy
// fans out to the ingest vs assets host by path.
POSTHOG_INGEST_HOST: z.string().default("eu.i.posthog.com"),
POSTHOG_ASSETS_HOST: z.string().default("eu-assets.i.posthog.com"),
// PostHog app host, used for the browser toolbar (ui_host) and the server
// client. Set to https://us.posthog.com for a US project (also switch the
// ingest/assets hosts to their us.i / us-assets equivalents).
POSTHOG_HOST: z.string().default("https://eu.posthog.com"),
TRIGGER_TELEMETRY_DISABLED: z.string().optional(),
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
+13 -3
View File
@@ -5,7 +5,12 @@ import { useOrganizationChanged } from "./useOrganizations";
import { useOptionalUser, useUserChanged } from "./useUser";
import { useProjectChanged } from "./useProject";
export const usePostHog = (apiKey?: string, logging = false, debug = false): void => {
export const usePostHog = (
apiKey?: string,
uiHost?: string,
logging = false,
debug = false
): void => {
const postHogInitialized = useRef(false);
const location = useLocation();
const user = useOptionalUser();
@@ -16,7 +21,12 @@ export const usePostHog = (apiKey?: string, logging = false, debug = false): voi
if (postHogInitialized.current === true) return;
if (logging) console.log("Initializing PostHog");
posthog.init(apiKey, {
api_host: "https://eu.posthog.com",
// Same-origin first-party proxy (see app/routes/ph.$.ts) that forwards to
// PostHog Cloud EU server-side.
api_host: "/ph",
// Point the toolbar at the real PostHog UI; without it, it falls back to /ph.
ui_host: uiHost,
cross_subdomain_cookie: true,
opt_in_site_apps: true,
debug,
loaded: function (posthog) {
@@ -28,7 +38,7 @@ export const usePostHog = (apiKey?: string, logging = false, debug = false): voi
},
});
postHogInitialized.current = true;
}, [apiKey, logging, user]);
}, [apiKey, uiHost, logging, user]);
useUserChanged((user) => {
if (postHogInitialized.current === false) return;
+4 -2
View File
@@ -51,6 +51,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const session = await getSession(request.headers.get("cookie"));
const toastMessage = session.get("toastMessage") as ToastMessage;
const posthogProjectKey = env.POSTHOG_PROJECT_KEY;
const posthogUiHost = env.POSTHOG_HOST;
const features = featuresForRequest(request);
const timezone = await getTimezonePreference(request);
@@ -68,6 +69,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
user,
toastMessage,
posthogProjectKey,
posthogUiHost,
features,
appEnv: env.APP_ENV,
appOrigin: env.APP_ORIGIN,
@@ -116,8 +118,8 @@ export function ErrorBoundary() {
}
export default function App() {
const { posthogProjectKey, kapa: _kapa } = useTypedLoaderData<typeof loader>();
usePostHog(posthogProjectKey);
const { posthogProjectKey, posthogUiHost, kapa: _kapa } = useTypedLoaderData<typeof loader>();
usePostHog(posthogProjectKey, posthogUiHost);
return (
<>
+99
View File
@@ -0,0 +1,99 @@
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { env } from "~/env.server";
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
import { logger } from "~/services/logger.server";
// Same-origin reverse proxy for PostHog. posthog-js sets `api_host: "/ph"`, so
// analytics is served first-party and forwarded to PostHog Cloud server-side.
// Asset requests (recorder script, array bundles) go to the assets host and
// everything else (ingest, feature flags, session recording) to the ingest
// host, as PostHog's reverse-proxy docs require. Streaming and header handling
// mirror the Electric proxy in longPollingFetch.ts.
const PH_PREFIX = "/ph";
function isAssetPath(upstreamPath: string): boolean {
return upstreamPath.startsWith("/static/") || upstreamPath.startsWith("/array/");
}
async function proxyToPostHog(request: Request): Promise<Response> {
const url = new URL(request.url);
const upstreamPath = url.pathname.slice(PH_PREFIX.length) || "/";
const hostname = isAssetPath(upstreamPath) ? env.POSTHOG_ASSETS_HOST : env.POSTHOG_INGEST_HOST;
const upstreamUrl = `https://${hostname}${upstreamPath}${url.search}`;
const headers = new Headers(request.headers);
// PostHog routes on Host, so point it at the upstream. accept-encoding is
// dropped so we don't get a compressed body we'd have to re-describe.
headers.set("host", hostname);
headers.delete("accept-encoding");
// /ph is same-origin, so the browser sends every first-party cookie. Forward
// only PostHog's own so we never leak the app session cookie to a third party.
const cookie = headers.get("cookie");
if (cookie) {
const forwarded = cookie
.split(";")
.filter((c) => {
const name = c.trimStart().split("=", 1)[0];
return name.startsWith("ph_") || name.startsWith("__ph");
})
.join(";");
if (forwarded) {
headers.set("cookie", forwarded);
} else {
headers.delete("cookie");
}
}
const hasBody = request.method !== "GET" && request.method !== "HEAD";
// `duplex` isn't in the DOM RequestInit lib types but undici needs it to
// stream a request body; widen the type rather than suppress.
const init: RequestInit & { duplex?: "half" } = {
method: request.method,
headers,
body: hasBody ? request.body : undefined,
signal: getRequestAbortSignal(),
duplex: hasBody ? "half" : undefined,
};
let upstream: Response | undefined;
try {
upstream = await fetch(upstreamUrl, init);
// Strip encoding headers that can misdescribe the proxied body (see longPollingFetch).
const responseHeaders = new Headers(upstream.headers);
responseHeaders.delete("content-encoding");
responseHeaders.delete("content-length");
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
});
} catch (error) {
try {
await upstream?.body?.cancel();
} catch {}
if (error instanceof Error && error.name === "AbortError") {
throw new Response(null, { status: 499 });
}
logger.error("[posthog-proxy] fetch error", {
error: error instanceof Error ? error.message : String(error),
});
throw new Response("PostHog proxy error", { status: 502 });
}
}
export async function loader({ request }: LoaderFunctionArgs) {
return proxyToPostHog(request);
}
export async function action({ request }: ActionFunctionArgs) {
return proxyToPostHog(request);
}
+1 -1
View File
@@ -22,7 +22,7 @@ class Telemetry {
}
if (postHogApiKey) {
this.#posthogClient = new PostHog(postHogApiKey, { host: "https://eu.posthog.com" });
this.#posthogClient = new PostHog(postHogApiKey, { host: env.POSTHOG_HOST });
} else {
console.log("No PostHog API key, so analytics won't track");
}
+3 -2
View File
@@ -150,8 +150,9 @@ if (ENABLE_CLUSTER && cluster.isPrimary) {
res.set("X-Robots-Tag", "noindex, nofollow");
}
// /clean-urls/ -> /clean-urls
if (req.path.endsWith("/") && req.path.length > 1) {
// /clean-urls/ -> /clean-urls. Skip /ph: PostHog ingest endpoints end in
// a slash, and a 301 would drop sendBeacon POSTs.
if (req.path.endsWith("/") && req.path.length > 1 && !req.path.startsWith("/ph/")) {
const query = req.url.slice(req.path.length);
const safepath = req.path.slice(0, -1).replace(/\/+/g, "/");
res.redirect(301, safepath + query);