diff --git a/.server-changes/posthog-reverse-proxy.md b/.server-changes/posthog-reverse-proxy.md new file mode 100644 index 0000000000..8391626761 --- /dev/null +++ b/.server-changes/posthog-reverse-proxy.md @@ -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. diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 04c18acb7a..cabe0159a6 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -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(), diff --git a/apps/webapp/app/hooks/usePostHog.ts b/apps/webapp/app/hooks/usePostHog.ts index bd536338e3..887151ca39 100644 --- a/apps/webapp/app/hooks/usePostHog.ts +++ b/apps/webapp/app/hooks/usePostHog.ts @@ -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; diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index 66a33b5ce1..07de36a60e 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -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(); - usePostHog(posthogProjectKey); + const { posthogProjectKey, posthogUiHost, kapa: _kapa } = useTypedLoaderData(); + usePostHog(posthogProjectKey, posthogUiHost); return ( <> diff --git a/apps/webapp/app/routes/ph.$.ts b/apps/webapp/app/routes/ph.$.ts new file mode 100644 index 0000000000..6ef67a6974 --- /dev/null +++ b/apps/webapp/app/routes/ph.$.ts @@ -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 { + 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); +} diff --git a/apps/webapp/app/services/telemetry.server.ts b/apps/webapp/app/services/telemetry.server.ts index eb12172816..f8a1ff93c6 100644 --- a/apps/webapp/app/services/telemetry.server.ts +++ b/apps/webapp/app/services/telemetry.server.ts @@ -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"); } diff --git a/apps/webapp/server.ts b/apps/webapp/server.ts index d81cc05b5a..c3c1a45f63 100644 --- a/apps/webapp/server.ts +++ b/apps/webapp/server.ts @@ -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);