mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 09:30:59 +00:00
feat: setup posthog
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
140
apps/edr-freight-web/backoffice/src/lib/posthog.ts
Normal file
140
apps/edr-freight-web/backoffice/src/lib/posthog.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user