ai-chatbot/components/agents/use-agent-style-performance-monitor.ts

246 lines
7.3 KiB
TypeScript

"use client";
import { useEffect, type RefObject } from "react";
import { isAgentModeScopedChatId } from "@/lib/chat/messages-route-access";
export type AgentStyleSection =
| "brand"
| "style"
| "mode"
| "materials"
| "generate"
| "references"
| "results"
| "unknown";
export type AgentStylePerformanceEvent = {
eventType: string;
durationMs: number;
blockingDurationMs?: number;
section: AgentStyleSection;
action?: string;
domNodeCount?: number;
visibleImageCount?: number;
loadedImageCount?: number;
viewport?: {
width: number;
height: number;
dpr: number;
};
browser?: string;
path?: string;
projectId?: string | null;
chatId?: string | null;
timestamp: string;
};
type UseAgentStylePerformanceMonitorParams = {
rootRef: RefObject<HTMLElement | null>;
chatId?: string | null;
projectId?: string | null;
};
const DEFAULT_SAMPLE_RATE = 0.2;
const SLOW_FRAME_THRESHOLD_MS = 120;
const MAX_EVENTS_PER_MINUTE = 20;
function getBooleanEnvFlag(value: string | undefined, fallback: boolean) {
if (value === undefined || value === "") return fallback;
return !["0", "false", "off", "no"].includes(value.toLowerCase());
}
function getSampleRate() {
const raw = Number(process.env.NEXT_PUBLIC_AGENT_STYLE_PERF_SAMPLE_RATE);
if (!Number.isFinite(raw)) return DEFAULT_SAMPLE_RATE;
return Math.min(1, Math.max(0, raw));
}
function getBrowserLabel() {
const ua = navigator.userAgent || "";
if (/Edg\//.test(ua)) return "edge";
if (/Chrome\//.test(ua)) return "chrome";
if (/Safari\//.test(ua) && !/Chrome\//.test(ua)) return "safari";
if (/Firefox\//.test(ua)) return "firefox";
return "unknown";
}
function resolveSection(target: EventTarget | null): AgentStyleSection {
if (!(target instanceof Element)) return "unknown";
const element = target.closest<HTMLElement>("[data-agent-style-section]");
const value = element?.dataset.agentStyleSection || "unknown";
if (
value === "brand" ||
value === "style" ||
value === "mode" ||
value === "materials" ||
value === "generate" ||
value === "references" ||
value === "results"
) {
return value;
}
return "unknown";
}
function resolveAction(target: EventTarget | null, fallback: string) {
if (!(target instanceof Element)) return fallback;
const explicit = target.closest<HTMLElement>("[data-agent-style-action]")?.dataset
.agentStyleAction;
if (explicit) return explicit.slice(0, 64);
const element = target.closest<HTMLElement>("button,input,textarea,select,[role='button']");
if (!element) return fallback;
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
return `${fallback}:${element.tagName.toLowerCase()}`;
}
return `${fallback}:${String(element.textContent || element.ariaLabel || element.tagName)
.trim()
.slice(0, 32)}`;
}
function collectCounts(root: HTMLElement) {
const domNodeCount = root.querySelectorAll("*").length;
const images = Array.from(root.querySelectorAll("img"));
let visibleImageCount = 0;
let loadedImageCount = 0;
for (const image of images) {
const rect = image.getBoundingClientRect();
const visible =
rect.bottom >= 0 &&
rect.right >= 0 &&
rect.top <= window.innerHeight &&
rect.left <= window.innerWidth;
if (visible) visibleImageCount += 1;
if (image.complete && image.naturalWidth > 0) loadedImageCount += 1;
}
return {
domNodeCount,
visibleImageCount,
loadedImageCount,
};
}
function postEvent(event: AgentStylePerformanceEvent) {
const body = JSON.stringify(event);
if (navigator.sendBeacon) {
const blob = new Blob([body], { type: "application/json" });
if (navigator.sendBeacon("/api/client-performance/agent-style", blob)) {
return;
}
}
void fetch("/api/client-performance/agent-style", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
keepalive: true,
}).catch(() => {});
}
export function useAgentStylePerformanceMonitor({
rootRef,
chatId,
projectId,
}: UseAgentStylePerformanceMonitorParams) {
const persistedChatId =
chatId && !isAgentModeScopedChatId(chatId) ? chatId : null;
useEffect(() => {
if (
!getBooleanEnvFlag(process.env.NEXT_PUBLIC_AGENT_STYLE_PERF_MONITOR, true) ||
Math.random() > getSampleRate()
) {
return;
}
const root = rootRef.current;
if (!root || typeof PerformanceObserver === "undefined") return;
let eventCount = 0;
let minuteWindowStart = Date.now();
let lastSection: AgentStyleSection = "unknown";
let lastAction = "idle";
const browser = getBrowserLabel();
const markAction = (event: Event) => {
lastSection = resolveSection(event.target);
lastAction = resolveAction(event.target, event.type);
};
root.addEventListener("click", markAction, true);
root.addEventListener("input", markAction, true);
root.addEventListener("change", markAction, true);
root.addEventListener("keydown", markAction, true);
root.addEventListener("scroll", markAction, true);
const trySend = (entry: PerformanceEntry, eventType: string) => {
const now = Date.now();
if (now - minuteWindowStart >= 60_000) {
minuteWindowStart = now;
eventCount = 0;
}
if (eventCount >= MAX_EVENTS_PER_MINUTE || entry.duration < SLOW_FRAME_THRESHOLD_MS) {
return;
}
eventCount += 1;
const anyEntry = entry as PerformanceEntry & {
blockingDuration?: number;
renderStart?: number;
styleAndLayoutStart?: number;
};
const counts = collectCounts(root);
postEvent({
eventType,
durationMs: Math.round(entry.duration),
blockingDurationMs: Math.round(anyEntry.blockingDuration || 0),
section: lastSection,
action: lastAction,
...counts,
viewport: {
width: window.innerWidth,
height: window.innerHeight,
dpr: window.devicePixelRatio || 1,
},
browser,
path: window.location.pathname,
projectId: projectId || null,
chatId: persistedChatId,
timestamp: new Date().toISOString(),
});
};
const observers: PerformanceObserver[] = [];
const supported = PerformanceObserver.supportedEntryTypes || [];
if (supported.includes("long-animation-frame")) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
trySend(entry, "long-animation-frame");
}
});
observer.observe({ type: "long-animation-frame", buffered: true });
observers.push(observer);
}
if (supported.includes("longtask")) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
trySend(entry, "longtask");
}
});
observer.observe({ type: "longtask", buffered: true });
observers.push(observer);
}
return () => {
for (const observer of observers) {
observer.disconnect();
}
root.removeEventListener("click", markAction, true);
root.removeEventListener("input", markAction, true);
root.removeEventListener("change", markAction, true);
root.removeEventListener("keydown", markAction, true);
root.removeEventListener("scroll", markAction, true);
};
}, [persistedChatId, projectId, rootRef]);
}