246 lines
7.3 KiB
TypeScript
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]);
|
|
}
|