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..ff0b05c384 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -196,6 +196,11 @@ 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"), 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..a891c2fa17 100644 --- a/apps/webapp/app/hooks/usePostHog.ts +++ b/apps/webapp/app/hooks/usePostHog.ts @@ -16,7 +16,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: "https://eu.posthog.com", + cross_subdomain_cookie: true, opt_in_site_apps: true, debug, loaded: function (posthog) { 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/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);