Files
edr-platform/apps/edr-freight-web/backoffice/src/lib/posthog.ts
2026-07-16 08:52:29 +00:00

141 lines
3.9 KiB
TypeScript

/**
* 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,
]);
}