Skip to content

Commit 54b3dea

Browse files
authored
add posthog to cloud app (#447)
* add posthog to cloud app posthog-js initialised in routes/__root.tsx alongside the existing Sentry init, wrapped in PostHogProvider, and identified against the WorkOS user + org in web/auth.tsx. Events route through a /ingest/* reverse proxy on the same worker so adblockers don't drop them; project key (phc_…) ships as a public var in wrangler.jsonc. * fix exhaustive-deps in posthog identify effect
1 parent 430f1d4 commit 54b3dea

7 files changed

Lines changed: 112 additions & 4 deletions

File tree

‎apps/cloud/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"drizzle-orm": "catalog:",
6464
"effect": "catalog:",
6565
"jose": "^5.6.3",
66+
"posthog-js": "^1.372.5",
6667
"postgres": "^3.4.9",
6768
"react": "catalog:",
6869
"react-dom": "catalog:",

‎apps/cloud/src/env-augment.d.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ declare global {
1212
AXIOM_TRACES_SAMPLE_RATIO?: string;
1313
SENTRY_DSN?: string;
1414
VITE_PUBLIC_SENTRY_DSN?: string;
15+
VITE_PUBLIC_POSTHOG_KEY?: string;
16+
VITE_PUBLIC_POSTHOG_HOST?: string;
1517

1618
// Datastore. Prod uses HYPERDRIVE when the binding exists; direct
1719
// DATABASE_URL is only selected when explicitly requested for local/test.

‎apps/cloud/src/routes/__root.tsx‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import React from "react";
22
import * as Sentry from "@sentry/react";
33
import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router";
44
import { AutumnProvider } from "autumn-js/react";
5+
import posthog from "posthog-js";
6+
import { PostHogProvider } from "posthog-js/react";
57
import { ExecutorProvider } from "@executor/react/api/provider";
68
import { Skeleton } from "@executor/react/components/skeleton";
79
import { Toaster } from "@executor/react/components/sonner";
@@ -21,6 +23,15 @@ if (typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_SENTRY_DSN) {
2123
});
2224
}
2325

26+
if (typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_POSTHOG_KEY) {
27+
posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, {
28+
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST ?? `${window.location.origin}/ingest`,
29+
ui_host: "https://us.posthog.com",
30+
defaults: "2025-05-24",
31+
person_profiles: "identified_only",
32+
});
33+
}
34+
2435
export const Route = createRootRoute({
2536
head: () => ({
2637
meta: [
@@ -63,9 +74,11 @@ function RootDocument({ children }: { children: React.ReactNode }) {
6374

6475
function RootComponent() {
6576
return (
66-
<AuthProvider>
67-
<AuthGate />
68-
</AuthProvider>
77+
<PostHogProvider client={posthog}>
78+
<AuthProvider>
79+
<AuthGate />
80+
</AuthProvider>
81+
</PostHogProvider>
6982
);
7083
}
7184

‎apps/cloud/src/start.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,36 @@ const sentryTunnelMiddleware = createMiddleware({ type: "request" }).server(
105105
},
106106
);
107107

108+
// ---------------------------------------------------------------------------
109+
// PostHog reverse proxy — the browser SDK targets `/ingest/*` (configured in
110+
// routes/__root.tsx) and we forward to PostHog's ingest + asset hosts. Keeps
111+
// events flowing past adblockers that match *.posthog.com. See
112+
// https://posthog.com/docs/advanced/proxy/cloudflare
113+
// ---------------------------------------------------------------------------
114+
115+
const POSTHOG_INGEST_HOST = "us.i.posthog.com";
116+
const POSTHOG_ASSETS_HOST = "us-assets.i.posthog.com";
117+
118+
const posthogProxyMiddleware = createMiddleware({ type: "request" }).server(
119+
({ pathname, request, next }) => {
120+
if (pathname !== "/ingest" && !pathname.startsWith("/ingest/")) {
121+
return next();
122+
}
123+
124+
const url = new URL(request.url);
125+
url.hostname = pathname.startsWith("/ingest/static/")
126+
? POSTHOG_ASSETS_HOST
127+
: POSTHOG_INGEST_HOST;
128+
url.protocol = "https:";
129+
url.port = "";
130+
url.pathname = pathname.replace(/^\/ingest/, "") || "/";
131+
132+
const upstream = new Request(url, request);
133+
upstream.headers.delete("cookie");
134+
return fetch(upstream);
135+
},
136+
);
137+
108138
// ---------------------------------------------------------------------------
109139
// API middleware — routes /api/* to the Effect HTTP layer
110140
// ---------------------------------------------------------------------------
@@ -125,6 +155,7 @@ export const startInstance = createStart(() => ({
125155
marketingMiddleware,
126156
mcpRequestMiddleware,
127157
sentryTunnelMiddleware,
158+
posthogProxyMiddleware,
128159
apiRequestMiddleware,
129160
],
130161
}));

‎apps/cloud/src/web/auth.tsx‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import React, { createContext, useContext } from "react";
1+
import React, { createContext, useContext, useEffect } from "react";
22
import { Atom } from "@effect-atom/atom";
33
import { useAtomValue, Result } from "@effect-atom/atom-react";
4+
import { usePostHog } from "posthog-js/react";
45
import { ReactivityKey } from "@executor/react/api/reactivity-keys";
56

67
import { CloudApiClient } from "./client";
@@ -55,6 +56,7 @@ export const useAuth = () => useContext(AuthContext);
5556

5657
const AuthProviderClient = ({ children }: { children: React.ReactNode }) => {
5758
const result = useAtomValue(authAtom);
59+
const posthog = usePostHog();
5860

5961
const state: AuthState = Result.match(result, {
6062
onInitial: () => ({ status: "loading" as const }),
@@ -66,6 +68,25 @@ const AuthProviderClient = ({ children }: { children: React.ReactNode }) => {
6668
onFailure: () => ({ status: "unauthenticated" as const }),
6769
});
6870

71+
const userId = state.status === "authenticated" ? state.user.id : null;
72+
const email = state.status === "authenticated" ? state.user.email : null;
73+
const name = state.status === "authenticated" ? state.user.name : null;
74+
const orgId = state.status === "authenticated" ? state.organization?.id ?? null : null;
75+
const orgName = state.status === "authenticated" ? state.organization?.name ?? null : null;
76+
const isUnauthenticated = state.status === "unauthenticated";
77+
78+
useEffect(() => {
79+
if (!posthog) return;
80+
if (userId) {
81+
posthog.identify(userId, { email, name });
82+
if (orgId) {
83+
posthog.group("organization", orgId, { name: orgName });
84+
}
85+
} else if (isUnauthenticated) {
86+
posthog.reset();
87+
}
88+
}, [posthog, userId, email, name, orgId, orgName, isUnauthenticated]);
89+
6990
return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;
7091
};
7192

‎apps/cloud/wrangler.jsonc‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,6 @@
5353
],
5454
"vars": {
5555
"VITE_PUBLIC_SITE_URL": "https://executor.sh",
56+
"VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL",
5657
},
5758
}

‎bun.lock‎

Lines changed: 39 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)