feat: setup posthog

This commit is contained in:
Nathnael
2026-07-16 08:52:29 +00:00
parent 6dde17fa4d
commit 318af79962
19 changed files with 513 additions and 190 deletions

View File

@@ -4,3 +4,8 @@ VITE_BASE_API_URL=http://localhost:3001
# Proactive token refresh cadence (minutes). Must stay well under the 60-min
# server session window. Default: 10.
VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10
# PostHog — session replay, error tracking, console logs. Both must be set or
# observability stays off (the app works either way). Self-hosted instance.
VITE_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
VITE_POSTHOG_HOST=https://posthog.example.com

View File

@@ -20,6 +20,7 @@
"@mantine/core": "^9.3.0",
"@mantine/dates": "^9.3.0",
"@mantine/hooks": "^9.3.0",
"@posthog/react": "^1.10.3",
"@radix-ui/react-accordion": "^1.2.13",
"@radix-ui/react-alert-dialog": "^1.1.16",
"@radix-ui/react-avatar": "^1.1.12",
@@ -76,6 +77,7 @@
"lucide-react": "^1.14.0",
"next-themes": "^0.4.6",
"pdf-lib": "^1.17.1",
"posthog-js": "^1.400.1",
"prop-types": "^15.8.1",
"qs": "^6.15.2",
"radix-ui": "^1.4.3",

View File

@@ -7,6 +7,7 @@ import {
type ReactNode,
} from "react";
import { useIdentify } from "@/lib/posthog";
import { getMeRequest, loginRequest, verifyMfaRequest } from "./api";
import {
AUTH_TOKEN_COOKIE,
@@ -69,6 +70,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [loading, setLoading] = useState(true);
const mfaEmailRef = useRef<string | null>(null);
// Attribute replays and exceptions to the signed-in user (id/org only).
useIdentify(user);
const loadCurrentUser = async () => {
const currentUser = await getMeRequest();
setUser(currentUser);

View File

@@ -5,6 +5,7 @@ import {
emitApiError,
extractApiErrorPayload,
} from "@/components/errors/ApiErrorModal";
import { captureApiError } from "@/lib/posthog";
import {
AUTH_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
@@ -82,6 +83,14 @@ api.interceptors.response.use(
async (error) => {
const originalRequest = error.config as RetriableRequest | undefined;
// Report the failure to PostHog. Hooked here rather than inside
// `emitApiError`, which stays silent on suppressed paths (warehouse /
// mile / onboarding) — those failures still need reporting.
// 401s are skipped: an expired session is refreshed below, not a defect.
if (!error.response || error.response.status !== 401) {
captureApiError(error);
}
if (
error.response?.status !== 401 ||
!originalRequest ||

View File

@@ -1,5 +1,7 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { captureException } from "@/lib/posthog";
interface ErrorBoundaryProps {
children: ReactNode;
}
@@ -21,6 +23,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
}
componentDidCatch(error: Error, info: ErrorInfo) {
captureException(error, { componentStack: info.componentStack });
// eslint-disable-next-line no-console
console.error("[ErrorBoundary] Uncaught render error:", error, info.componentStack);
}

View File

@@ -0,0 +1,140 @@
/**
* PostHog wiring — session replay, exception capture, console logs.
*
* NOTE: `portal/src/lib/posthog.ts` is the twin of this file. The init config
* below (masking rules) and the PII allowlist in `useIdentify` MUST be kept
* identical in both — a change made here alone silently leaks staff data into
* the other app's replays.
*
* This is instrumentation, not analytics: autocapture is off and no product
* events are sent.
*/
import posthog from "posthog-js";
import { useEffect } from "react";
import type { AuthUser } from "@/auth/types";
const APP = "freight-backoffice";
const TOKEN = import.meta.env.VITE_POSTHOG_KEY;
const HOST = import.meta.env.VITE_POSTHOG_HOST;
/**
* Whether init actually ran. Without a token every export below is a no-op, so
* local dev and any environment whose env file lacks the vars keeps working —
* a missing observability token must never break the app.
*/
let enabled = false;
export function initPostHog(): void {
if (enabled || !TOKEN || !HOST) return;
posthog.init(TOKEN, {
api_host: HOST,
defaults: "2026-05-30",
// Debuggability, not analytics.
autocapture: false,
capture_pageview: true,
capture_pageleave: true,
disable_surveys: true,
person_profiles: "identified_only",
capture_exceptions: {
capture_unhandled_errors: true,
capture_unhandled_rejections: true,
capture_console_errors: true,
},
// Off by default; this is half the point of the integration.
enable_recording_console_log: true,
// Inputs are masked; rendered text stays visible so replays are readable.
// Wrap sensitive elements in `ph-no-capture` to blank them individually.
session_recording: { maskAllInputs: true },
});
// Portal and backoffice share one PostHog project — filter by this.
posthog.register({ app: APP });
enabled = true;
}
export function captureException(
error: unknown,
properties?: Record<string, unknown>,
): void {
if (!enabled) return;
posthog.captureException(error, properties);
}
/**
* Report a failed API call.
*
* Called from the axios interceptor rather than from `emitApiError`, which
* early-returns on suppressed paths (warehouse / first-mile / onboarding /
* auth) — hooking there would drop errors on exactly those pages.
*
* 5xx and network failures are real defects and go to Error tracking. 4xx is
* usually the server correctly rejecting input, so it is recorded as a plain
* event to keep the issue list signal-heavy.
*/
export function captureApiError(error: unknown): void {
if (!enabled) return;
const err = error as {
message?: string;
config?: { method?: string; url?: string };
response?: { status?: number };
};
const status = err.response?.status;
const properties = {
api_status: status ?? null,
api_method: err.config?.method?.toUpperCase() ?? null,
api_path: err.config?.url ?? null,
};
if (status && status < 500) {
posthog.capture("api_error", properties);
return;
}
posthog.captureException(error, properties);
}
/**
* Identify the current user to PostHog so replays and exceptions are
* attributable.
*
* Deliberately sends NO contact details. `AuthUser` carries email, phoneNumber,
* name and username; these are railway staff, and debugging a replay never
* requires knowing how to phone the person in it.
*/
export function useIdentify(user: AuthUser | null): void {
const employee = user?.employee?.[0];
useEffect(() => {
if (!enabled) return;
if (!user?.id) {
posthog.reset();
return;
}
posthog.identify(user.id, {
roles: user.roles?.map((role) => role.key ?? role.id),
status: user.status,
is_super_admin: user.isSuperAdmin,
organization_id: employee?.organizationId,
unit_id: employee?.unitId,
});
}, [
user?.id,
user?.roles,
user?.status,
user?.isSuperAdmin,
employee?.organizationId,
employee?.unitId,
]);
}

View File

@@ -2,6 +2,8 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { MantineProvider } from "@mantine/core";
import posthog from "posthog-js";
import { PostHogProvider } from "@posthog/react";
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@edr/ui-common/styles.css";
@@ -19,6 +21,7 @@ import { ApiErrorModal } from "./components/errors/ApiErrorModal";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { AuthProvider } from "./auth/AuthProvider";
import { queryClient } from "./lib/queryClient";
import { initPostHog } from "./lib/posthog";
import { freightMantineTheme } from "./theme/freight-brand";
import { QueryClientProvider } from "@tanstack/react-query";
@@ -43,6 +46,10 @@ const applyStoredTheme = () => {
applyStoredTheme();
// Must run before render so replay and exception capture cover startup errors.
// No-ops when VITE_POSTHOG_KEY is unset.
initPostHog();
const rootElement = document.getElementById("root");
if (!rootElement) {
@@ -50,23 +57,25 @@ if (!rootElement) {
}
createRoot(rootElement).render(
<QueryClientProvider client={queryClient}>
<MantineProvider theme={freightMantineTheme}>
<StrictMode>
<BrowserRouter>
<AuthProvider>
<ErrorBoundary>
<App />
</ErrorBoundary>
{/* Global API error modal — shows the server's actual error
message (suppressed on warehouse / mile / onboarding pages). */}
<ApiErrorModal />
<Toaster position="top-right" />
</AuthProvider>
</BrowserRouter>
</StrictMode>
</MantineProvider>
</QueryClientProvider>
<PostHogProvider client={posthog}>
<QueryClientProvider client={queryClient}>
<MantineProvider theme={freightMantineTheme}>
<StrictMode>
<BrowserRouter>
<AuthProvider>
<ErrorBoundary>
<App />
</ErrorBoundary>
{/* Global API error modal — shows the server's actual error
message (suppressed on warehouse / mile / onboarding pages). */}
<ApiErrorModal />
<Toaster position="top-right" />
</AuthProvider>
</BrowserRouter>
</StrictMode>
</MantineProvider>
</QueryClientProvider>
</PostHogProvider>
);
// run

View File

@@ -1,5 +1,7 @@
import React, { Component, ErrorInfo, ReactNode } from "react";
import { captureException } from "@/lib/posthog";
interface ErrorBoundaryProps {
children: ReactNode;
}
@@ -20,7 +22,8 @@ export class ErrorBoundary extends Component<
}
componentDidCatch(error: Error, info: ErrorInfo) {
// Log to console in development; replace with a reporting service (e.g. Sentry) in production
captureException(error, { componentStack: info.componentStack });
if (import.meta.env.DEV) {
console.error("ErrorBoundary caught:", error, info.componentStack);
}

View File

@@ -17,6 +17,11 @@ interface Window {
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_BASE_API_URL: string;
readonly VITE_TOKEN_REFRESH_INTERVAL_MINUTES?: string;
/** PostHog project token. Absent = observability disabled (see lib/posthog.ts). */
readonly VITE_POSTHOG_KEY?: string;
/** Self-hosted PostHog instance URL. */
readonly VITE_POSTHOG_HOST?: string;
}
interface ImportMeta {

View File

@@ -18,6 +18,7 @@
"@mantine/core": "^9.3.0",
"@mantine/dates": "^9.3.0",
"@mantine/hooks": "^9.3.0",
"@posthog/react": "^1.10.3",
"@tanstack/react-query": "^5.59.0",
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
"@vis.gl/react-google-maps": "^1.8.3",
@@ -26,6 +27,7 @@
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"lucide-react": "^1.14.0",
"posthog-js": "^1.400.1",
"radix-ui": "^1.4.3",
"react": "19.2.6",
"react-dom": "19.2.6",

View File

@@ -25,6 +25,7 @@ import OnboardingResumeBanner, {
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
import { ApiErrorModal } from "./components/errors/ApiErrorModal";
import useAuth from "./hooks/useAuth";
import { useIdentify } from "./lib/posthog";
import {
startTokenRefreshScheduler,
stopTokenRefreshScheduler,
@@ -221,6 +222,9 @@ const App = () => {
const { user, company, companyType, createProfile, isAuthenticated } =
useAuth();
// Attribute replays and exceptions to the signed-in user (id/org only).
useIdentify(user, company);
// Keep the server session alive while a user is logged in. Runs after
// login, signup, and page-reload bootstrap alike.
useEffect(() => {

View File

@@ -0,0 +1,31 @@
/**
* Shown when a render error escapes to the app root.
*
* Before this existed, a render exception unmounted the tree and left the
* customer staring at a blank white page with no way forward. The matching
* `$exception` is reported by the surrounding PostHogErrorBoundary.
*/
export function AppErrorFallback() {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50 p-6">
<div className="w-full max-w-md rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">
Something went wrong
</h2>
<p className="mt-2 text-sm text-slate-600">
This page failed to load. The problem has been reported. Try reloading
if it keeps happening, please contact support.
</p>
<button
type="button"
onClick={() => window.location.reload()}
className="mt-4 rounded-lg bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800"
>
Reload page
</button>
</div>
</div>
);
}
export default AppErrorFallback;

View File

@@ -0,0 +1,151 @@
/**
* PostHog wiring — session replay, exception capture, console logs.
*
* NOTE: `backoffice/src/lib/posthog.ts` is the twin of this file. The init
* config below (masking rules) and the PII allowlist in `useIdentify` MUST be
* kept identical in both — a change made here alone silently leaks customer
* data into the other app's replays.
*
* This is instrumentation, not analytics: autocapture is off and no product
* events are sent.
*/
import posthog from "posthog-js";
import { useEffect } from "react";
import type { AuthUser } from "@/types/auth";
const APP = "freight-portal";
const TOKEN = import.meta.env.VITE_POSTHOG_KEY;
const HOST = import.meta.env.VITE_POSTHOG_HOST;
/**
* Whether init actually ran. Without a token every export below is a no-op, so
* local dev and any environment whose env file lacks the vars keeps working —
* a missing observability token must never break the app.
*/
let enabled = false;
export function initPostHog(): void {
if (enabled || !TOKEN || !HOST) return;
posthog.init(TOKEN, {
api_host: HOST,
defaults: "2026-05-30",
// Debuggability, not analytics.
autocapture: false,
capture_pageview: true,
capture_pageleave: true,
disable_surveys: true,
person_profiles: "identified_only",
capture_exceptions: {
capture_unhandled_errors: true,
capture_unhandled_rejections: true,
capture_console_errors: true,
},
// Off by default; this is half the point of the integration.
enable_recording_console_log: true,
// Inputs are masked; rendered text stays visible so replays are readable.
// Wrap sensitive elements in `ph-no-capture` to blank them individually.
session_recording: { maskAllInputs: true },
});
// Portal and backoffice share one PostHog project — filter by this.
posthog.register({ app: APP });
enabled = true;
}
export function captureException(
error: unknown,
properties?: Record<string, unknown>,
): void {
if (!enabled) return;
posthog.captureException(error, properties);
}
/**
* Report a failed API call.
*
* Called from the axios interceptor rather than from `emitApiError`, which
* early-returns on suppressed paths (warehouse / first-mile / onboarding /
* auth) — hooking there would drop errors on exactly those pages.
*
* 5xx and network failures are real defects and go to Error tracking. 4xx is
* usually the server correctly rejecting input, so it is recorded as a plain
* event to keep the issue list signal-heavy.
*/
export function captureApiError(error: unknown): void {
if (!enabled) return;
const err = error as {
message?: string;
config?: { method?: string; url?: string };
response?: { status?: number };
};
const status = err.response?.status;
const properties = {
api_status: status ?? null,
api_method: err.config?.method?.toUpperCase() ?? null,
api_path: err.config?.url ?? null,
};
if (status && status < 500) {
posthog.capture("api_error", properties);
return;
}
posthog.captureException(error, properties);
}
/** Company context, as returned by `useAuth().company`. */
interface IdentifyCompany {
company?: { id?: string; type?: string | null; status?: string | null } | null;
profile?: { activeProfileType?: string | null } | null;
}
/**
* Identify the current user to PostHog so replays and exceptions are
* attributable.
*
* Deliberately sends NO contact details. `AuthUser` carries email, phoneNumber,
* name and username; these are real customers, and debugging a replay never
* requires knowing how to phone the person in it.
*/
export function useIdentify(
user: AuthUser | null,
company?: IdentifyCompany | null,
): void {
useEffect(() => {
if (!enabled) return;
if (!user?.id) {
posthog.reset();
return;
}
posthog.identify(user.id, {
roles: user.roles,
status: user.status,
user_type: user.userType,
company_id: company?.company?.id,
company_type: company?.company?.type,
company_status: company?.company?.status,
active_profile_type: company?.profile?.activeProfileType,
});
}, [
user?.id,
user?.roles,
user?.status,
user?.userType,
company?.company?.id,
company?.company?.type,
company?.company?.status,
company?.profile?.activeProfileType,
]);
}

View File

@@ -3,6 +3,8 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MantineProvider } from "@mantine/core";
import posthog from "posthog-js";
import { PostHogProvider, PostHogErrorBoundary } from "@posthog/react";
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@edr/ui-common/styles.css";
@@ -10,6 +12,8 @@ import "../index.css";
import "@edr/ui-common/theme.css";
import { Toaster } from "react-hot-toast";
import { mantineTheme } from "./theme/mantine";
import { initPostHog } from "./lib/posthog";
import { AppErrorFallback } from "./components/errors/AppErrorFallback";
import App from "./App";
@@ -26,6 +30,10 @@ import App from "./App";
}
});
// Must run before render so replay and exception capture cover startup errors.
// No-ops when VITE_POSTHOG_KEY is unset.
initPostHog();
const queryClient = new QueryClient();
const rootElement = document.getElementById("root");
@@ -36,13 +44,17 @@ if (!rootElement) {
createRoot(document.getElementById("root")!).render(
<StrictMode>
<MantineProvider theme={mantineTheme} defaultColorScheme="light">
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<Toaster position="top-right" />
</BrowserRouter>
</QueryClientProvider>
</MantineProvider>
<PostHogProvider client={posthog}>
<MantineProvider theme={mantineTheme} defaultColorScheme="light">
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<PostHogErrorBoundary fallback={<AppErrorFallback />}>
<App />
</PostHogErrorBoundary>
<Toaster position="top-right" />
</BrowserRouter>
</QueryClientProvider>
</MantineProvider>
</PostHogProvider>
</StrictMode>,
);

View File

@@ -6,6 +6,7 @@ import {
emitApiError,
extractApiErrorPayload,
} from "@/components/errors/ApiErrorModal";
import { captureApiError } from "@/lib/posthog";
const client = axios.create({
baseURL: API_BASE_URL,
@@ -91,6 +92,14 @@ client.interceptors.response.use(
_retry?: boolean;
};
// Report the failure to PostHog. Hooked here rather than inside
// `emitApiError`, which stays silent on suppressed paths (warehouse /
// mile / onboarding) — those failures still need reporting.
// 401s are skipped: an expired session is refreshed below, not a defect.
if (!error.response || error.response.status !== 401) {
captureApiError(error);
}
// Don't intercept if:
// - no response (network error)
// - status is not 401

View File

@@ -16,7 +16,13 @@ interface Window {
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_BASE_API_URL: string;
readonly VITE_GOOGLE_MAPS_API_KEY?: string;
readonly VITE_TOKEN_REFRESH_INTERVAL_MINUTES?: string;
/** PostHog project token. Absent = observability disabled (see lib/posthog.ts). */
readonly VITE_POSTHOG_KEY?: string;
/** Self-hosted PostHog instance URL. */
readonly VITE_POSTHOG_HOST?: string;
}
interface ImportMeta {