Files
ERP/frontend/src/lib/analytics.ts
T
MMD e17d1f0883 feat(monitoring): observability stack + app instrumentation
- /health/ready (DB+MinIO+Casdoor checks, webhook post-deploy gate),
  /metrics via prometheus-fastapi-instrumentator, Sentry/GlitchTip capture,
  X-Request-ID middleware + JSON access logging (PROCUREMENT_LOG_FORMAT)
- server-side analytics (OpenPanel Track API): login, quote lifecycle,
  receipts — fire-and-forget, disabled without env config
- monitoring/ compose project: GlitchTip(+pg), Prometheus(+alerts),
  Grafana(provisioned ERP dashboard), Loki+Promtail, node/postgres
  exporters, cAdvisor — mem-limited (~2.2GB), loopback admin UIs,
  separate from app deploys; erp_shared network for scraping
- frontend: @openpanel/web + @sentry/react (build-arg gated, no-op when
  unset), funnel events (login, wizard steps, quote submitted)
- compose: api healthcheck, observability env wiring, .env.example keys
2026-09-18 22:18:02 +03:30

81 lines
2.4 KiB
TypeScript

// ============================================================
// Product analytics + error capture (Phase 2)
//
// - OpenPanel (@openpanel/web): funnels/usage — initialized only when
// VITE_OPENPANEL_URL and VITE_OPENPANEL_CLIENT_ID are set (build args),
// so local dev and air-gapped environments stay silent.
// - GlitchTip (@sentry/react): frontend exception capture, only when
// VITE_SENTRY_DSN is set.
//
// Both are no-ops without configuration; every helper is safe to call
// unconditionally from anywhere in the app.
// ============================================================
import * as Sentry from "@sentry/react";
type Props = Record<string, unknown>;
declare global {
interface Window {
op?: (...args: unknown[]) => void;
}
}
const OPENPANEL_URL = (import.meta.env?.VITE_OPENPANEL_URL as string | undefined)?.trim();
const OPENPANEL_CLIENT_ID = (import.meta.env?.VITE_OPENPANEL_CLIENT_ID as string | undefined)?.trim();
const SENTRY_DSN = (import.meta.env?.VITE_SENTRY_DSN as string | undefined)?.trim();
export function initAnalytics(): void {
if (SENTRY_DSN) {
Sentry.init({
dsn: SENTRY_DSN,
environment: (import.meta.env?.MODE as string | undefined) ?? "production",
// Frontend traces are expensive and rarely needed — errors only.
tracesSampleRate: 0,
});
}
if (OPENPANEL_URL && OPENPANEL_CLIENT_ID) {
try {
window.op?.("init", {
apiUrl: OPENPANEL_URL,
clientId: OPENPANEL_CLIENT_ID,
trackScreenViews: true, // pageview → which pages users visit
});
} catch {
/* analytics must never break the app */
}
}
}
/** Attach the ERP user identity to analytics (after login / app load). */
export function identifyUser(profile: {
id?: string;
name?: string;
role?: string | null;
}): void {
if (!profile.id) return;
try {
window.op?.("identify", {
profileId: profile.id,
firstName: profile.name || undefined,
data: { role: profile.role ?? undefined },
});
track("user_identified", { role: profile.role ?? undefined });
} catch {
/* ignore */
}
}
export function track(event: string, properties?: Props): void {
try {
window.op?.("track", event, properties ?? {});
} catch {
/* ignore */
}
}
export function captureError(err: unknown): void {
if (SENTRY_DSN) Sentry.captureException(err);
}
export const analyticsEnabled = Boolean(OPENPANEL_URL && OPENPANEL_CLIENT_ID);