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:
@@ -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.
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user