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
This commit is contained in:
@@ -19,6 +19,15 @@ RUN npm config set registry ${NPM_REGISTRY} && npm ci
|
||||
ARG DEV_LOGIN_ENABLED=false
|
||||
ENV VITE_ENABLE_DEV_LOGIN=${DEV_LOGIN_ENABLED}
|
||||
|
||||
# Observability (Phase 2) — baked into the bundle at build time.
|
||||
# Empty values disable the feature (analytics.ts is a no-op then).
|
||||
ARG VITE_OPENPANEL_URL=""
|
||||
ARG VITE_OPENPANEL_CLIENT_ID=""
|
||||
ARG VITE_SENTRY_DSN=""
|
||||
ENV VITE_OPENPANEL_URL=${VITE_OPENPANEL_URL} \
|
||||
VITE_OPENPANEL_CLIENT_ID=${VITE_OPENPANEL_CLIENT_ID} \
|
||||
VITE_SENTRY_DSN=${VITE_SENTRY_DSN}
|
||||
|
||||
# Source code + build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/react": "^10.0.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "3.10",
|
||||
"@openpanel/web": "^1.0.1",
|
||||
"@radix-ui/react-checkbox": "^1.3.11",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-label": "^2.1.15",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { captureError } from "../lib/analytics";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -19,6 +20,8 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("ErrorBoundary caught:", error, info);
|
||||
// GlitchTip/Sentry (no-op بدون VITE_SENTRY_DSN)
|
||||
captureError(error);
|
||||
}
|
||||
|
||||
private retry = () => {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// ============================================================
|
||||
// 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);
|
||||
@@ -6,6 +6,7 @@ import { Toaster } from "sonner";
|
||||
import App from "./App";
|
||||
import "./styles/globals.css";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import { initAnalytics } from "./lib/analytics";
|
||||
|
||||
// بعد از استقرار نسخهی جدید، مرورگری که index.html قدیمی را باز کرده برای
|
||||
// بارگذاری chunkهای حذفشده 404 میگیرد و صفحه خالی میماند. این شنونده،
|
||||
@@ -15,6 +16,9 @@ window.addEventListener("vite:preloadError", () => {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
// Product analytics + GlitchTip error capture — no-ops without VITE_ config.
|
||||
initAnalytics();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { ClipboardList, Eye, EyeOff, Loader2, Lock, ShieldCheck, Wrench, X } from "lucide-react";
|
||||
import { api, apiV26 } from "../../lib/api";
|
||||
import { identifyUser, track } from "../../lib/analytics";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import type { Project } from "../../lib/types";
|
||||
|
||||
@@ -78,6 +79,9 @@ export function LoginPage() {
|
||||
}
|
||||
setToken(access_token);
|
||||
setUser(user);
|
||||
// تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*)
|
||||
identifyUser({ id: user.id, name: user.display_name, role: user.role });
|
||||
track("login_success", { role: user.role });
|
||||
toast.success(`خوش آمدید، ${user.display_name}`);
|
||||
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
|
||||
navigate(user.role === "pm" ? "/inventory" : "/");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { track } from "../../lib/analytics";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -118,6 +119,7 @@ export function WizardPage() {
|
||||
const goToStep = (n: number) => {
|
||||
setCurrentStep(n);
|
||||
setHighestVisited((h) => Math.max(h, n));
|
||||
track("wizard_step", { step: n, direction: n > currentStep ? "forward" : "back" });
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
@@ -405,6 +407,7 @@ export function WizardPage() {
|
||||
}
|
||||
}
|
||||
toast.success("پیشنهاد ذخیره شد.");
|
||||
track("quote_submitted", { quote_id: created.id, items: lineItems.length });
|
||||
navigate("/");
|
||||
} catch (e) {
|
||||
const target = resolveErrorTarget(e);
|
||||
|
||||
Vendored
+9
@@ -1 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// Observability build-time configuration (empty = feature disabled)
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_OPENPANEL_URL?: string;
|
||||
readonly VITE_OPENPANEL_CLIENT_ID?: string;
|
||||
readonly VITE_SENTRY_DSN?: string;
|
||||
readonly VITE_ENABLE_DEV_LOGIN?: string;
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user