Skip to content

Commit 00ee075

Browse files
authored
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.
1 parent fbd86b6 commit 00ee075

7 files changed

Lines changed: 135 additions & 8 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Serve PostHog analytics from a same-origin `/ph` path that reverse-proxies to PostHog Cloud EU, following PostHog's first-party reverse-proxy guidance.

apps/webapp/app/env.server.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,15 @@ const EnvironmentSchema = z
196196
SERVICE_NAME: z.string().default("trigger.dev webapp"),
197197
SENTRY_DSN: z.string().optional(),
198198
POSTHOG_PROJECT_KEY: z.string().default("phc_LFH7kJiGhdIlnO22hTAKgHpaKhpM8gkzWAFvHmf5vfS"),
199+
// Upstream hosts the /ph reverse proxy forwards to (defaults: PostHog Cloud
200+
// EU). The client points api_host at the same-origin /ph path; the proxy
201+
// fans out to the ingest vs assets host by path.
202+
POSTHOG_INGEST_HOST: z.string().default("eu.i.posthog.com"),
203+
POSTHOG_ASSETS_HOST: z.string().default("eu-assets.i.posthog.com"),
204+
// PostHog app host, used for the browser toolbar (ui_host) and the server
205+
// client. Set to https://us.posthog.com for a US project (also switch the
206+
// ingest/assets hosts to their us.i / us-assets equivalents).
207+
POSTHOG_HOST: z.string().default("https://eu.posthog.com"),
199208
TRIGGER_TELEMETRY_DISABLED: z.string().optional(),
200209
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
201210
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),

apps/webapp/app/hooks/usePostHog.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import { useOrganizationChanged } from "./useOrganizations";
55
import { useOptionalUser, useUserChanged } from "./useUser";
66
import { useProjectChanged } from "./useProject";
77

8-
export const usePostHog = (apiKey?: string, logging = false, debug = false): void => {
8+
export const usePostHog = (
9+
apiKey?: string,
10+
uiHost?: string,
11+
logging = false,
12+
debug = false
13+
): void => {
914
const postHogInitialized = useRef(false);
1015
const location = useLocation();
1116
const user = useOptionalUser();
@@ -16,7 +21,12 @@ export const usePostHog = (apiKey?: string, logging = false, debug = false): voi
1621
if (postHogInitialized.current === true) return;
1722
if (logging) console.log("Initializing PostHog");
1823
posthog.init(apiKey, {
19-
api_host: "https://eu.posthog.com",
24+
// Same-origin first-party proxy (see app/routes/ph.$.ts) that forwards to
25+
// PostHog Cloud EU server-side.
26+
api_host: "/ph",
27+
// Point the toolbar at the real PostHog UI; without it, it falls back to /ph.
28+
ui_host: uiHost,
29+
cross_subdomain_cookie: true,
2030
opt_in_site_apps: true,
2131
debug,
2232
loaded: function (posthog) {
@@ -28,7 +38,7 @@ export const usePostHog = (apiKey?: string, logging = false, debug = false): voi
2838
},
2939
});
3040
postHogInitialized.current = true;
31-
}, [apiKey, logging, user]);
41+
}, [apiKey, uiHost, logging, user]);
3242

3343
useUserChanged((user) => {
3444
if (postHogInitialized.current === false) return;

apps/webapp/app/root.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
5151
const session = await getSession(request.headers.get("cookie"));
5252
const toastMessage = session.get("toastMessage") as ToastMessage;
5353
const posthogProjectKey = env.POSTHOG_PROJECT_KEY;
54+
const posthogUiHost = env.POSTHOG_HOST;
5455
const features = featuresForRequest(request);
5556
const timezone = await getTimezonePreference(request);
5657

@@ -68,6 +69,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
6869
user,
6970
toastMessage,
7071
posthogProjectKey,
72+
posthogUiHost,
7173
features,
7274
appEnv: env.APP_ENV,
7375
appOrigin: env.APP_ORIGIN,
@@ -116,8 +118,8 @@ export function ErrorBoundary() {
116118
}
117119

118120
export default function App() {
119-
const { posthogProjectKey, kapa: _kapa } = useTypedLoaderData<typeof loader>();
120-
usePostHog(posthogProjectKey);
121+
const { posthogProjectKey, posthogUiHost, kapa: _kapa } = useTypedLoaderData<typeof loader>();
122+
usePostHog(posthogProjectKey, posthogUiHost);
121123

122124
return (
123125
<>

apps/webapp/app/routes/ph.$.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
2+
import { env } from "~/env.server";
3+
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
4+
import { logger } from "~/services/logger.server";
5+
6+
// Same-origin reverse proxy for PostHog. posthog-js sets `api_host: "/ph"`, so
7+
// analytics is served first-party and forwarded to PostHog Cloud server-side.
8+
// Asset requests (recorder script, array bundles) go to the assets host and
9+
// everything else (ingest, feature flags, session recording) to the ingest
10+
// host, as PostHog's reverse-proxy docs require. Streaming and header handling
11+
// mirror the Electric proxy in longPollingFetch.ts.
12+
13+
const PH_PREFIX = "/ph";
14+
15+
function isAssetPath(upstreamPath: string): boolean {
16+
return upstreamPath.startsWith("/static/") || upstreamPath.startsWith("/array/");
17+
}
18+
19+
async function proxyToPostHog(request: Request): Promise<Response> {
20+
const url = new URL(request.url);
21+
22+
const upstreamPath = url.pathname.slice(PH_PREFIX.length) || "/";
23+
const hostname = isAssetPath(upstreamPath) ? env.POSTHOG_ASSETS_HOST : env.POSTHOG_INGEST_HOST;
24+
const upstreamUrl = `https://${hostname}${upstreamPath}${url.search}`;
25+
26+
const headers = new Headers(request.headers);
27+
// PostHog routes on Host, so point it at the upstream. accept-encoding is
28+
// dropped so we don't get a compressed body we'd have to re-describe.
29+
headers.set("host", hostname);
30+
headers.delete("accept-encoding");
31+
32+
// /ph is same-origin, so the browser sends every first-party cookie. Forward
33+
// only PostHog's own so we never leak the app session cookie to a third party.
34+
const cookie = headers.get("cookie");
35+
if (cookie) {
36+
const forwarded = cookie
37+
.split(";")
38+
.filter((c) => {
39+
const name = c.trimStart().split("=", 1)[0];
40+
return name.startsWith("ph_") || name.startsWith("__ph");
41+
})
42+
.join(";");
43+
44+
if (forwarded) {
45+
headers.set("cookie", forwarded);
46+
} else {
47+
headers.delete("cookie");
48+
}
49+
}
50+
51+
const hasBody = request.method !== "GET" && request.method !== "HEAD";
52+
53+
// `duplex` isn't in the DOM RequestInit lib types but undici needs it to
54+
// stream a request body; widen the type rather than suppress.
55+
const init: RequestInit & { duplex?: "half" } = {
56+
method: request.method,
57+
headers,
58+
body: hasBody ? request.body : undefined,
59+
signal: getRequestAbortSignal(),
60+
duplex: hasBody ? "half" : undefined,
61+
};
62+
63+
let upstream: Response | undefined;
64+
try {
65+
upstream = await fetch(upstreamUrl, init);
66+
67+
// Strip encoding headers that can misdescribe the proxied body (see longPollingFetch).
68+
const responseHeaders = new Headers(upstream.headers);
69+
responseHeaders.delete("content-encoding");
70+
responseHeaders.delete("content-length");
71+
72+
return new Response(upstream.body, {
73+
status: upstream.status,
74+
statusText: upstream.statusText,
75+
headers: responseHeaders,
76+
});
77+
} catch (error) {
78+
try {
79+
await upstream?.body?.cancel();
80+
} catch {}
81+
82+
if (error instanceof Error && error.name === "AbortError") {
83+
throw new Response(null, { status: 499 });
84+
}
85+
86+
logger.error("[posthog-proxy] fetch error", {
87+
error: error instanceof Error ? error.message : String(error),
88+
});
89+
throw new Response("PostHog proxy error", { status: 502 });
90+
}
91+
}
92+
93+
export async function loader({ request }: LoaderFunctionArgs) {
94+
return proxyToPostHog(request);
95+
}
96+
97+
export async function action({ request }: ActionFunctionArgs) {
98+
return proxyToPostHog(request);
99+
}

apps/webapp/app/services/telemetry.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class Telemetry {
2222
}
2323

2424
if (postHogApiKey) {
25-
this.#posthogClient = new PostHog(postHogApiKey, { host: "https://eu.posthog.com" });
25+
this.#posthogClient = new PostHog(postHogApiKey, { host: env.POSTHOG_HOST });
2626
} else {
2727
console.log("No PostHog API key, so analytics won't track");
2828
}

apps/webapp/server.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,9 @@ if (ENABLE_CLUSTER && cluster.isPrimary) {
150150
res.set("X-Robots-Tag", "noindex, nofollow");
151151
}
152152

153-
// /clean-urls/ -> /clean-urls
154-
if (req.path.endsWith("/") && req.path.length > 1) {
153+
// /clean-urls/ -> /clean-urls. Skip /ph: PostHog ingest endpoints end in
154+
// a slash, and a 301 would drop sendBeacon POSTs.
155+
if (req.path.endsWith("/") && req.path.length > 1 && !req.path.startsWith("/ph/")) {
155156
const query = req.url.slice(req.path.length);
156157
const safepath = req.path.slice(0, -1).replace(/\/+/g, "/");
157158
res.redirect(301, safepath + query);

0 commit comments

Comments
 (0)