GenerAI / worldmonitor /src /bootstrap /lcp-attribution.ts
amogaddy's picture
Integra World Monitor (AGPL-3.0, self-hosted) nello Space: pagina, menu, e arricchimento notizie per la AI (part 6)
fa9c65f verified
Raw
History Blame Contribute Delete
11.5 kB
import { markLcpDebug, type LcpMarkSnapshot } from '@/utils/lcp-debug';
import { sanitizeWebVitalUrl } from '@/bootstrap/web-vitals-utils';
type LcpElementSnapshot = {
className: string;
closest: string;
id: string;
selector: string;
tagName: string;
// Redacted by default ('') — the LCP element's textContent can hold user/PII
// content. Populated only when the explicit wm_lcp_text flag is set. Use
// textLength to see whether the candidate was a text node without exposing it.
text: string;
textLength: number;
};
type LcpResourceGroup = {
category: string;
count: number;
encodedBodySize: number;
transferSize: number;
largest?: {
duration: number;
initiatorType: string;
name: string;
startTime: number;
transferSize: number;
};
};
type LcpContextSnapshot = {
devicePixelRatio: number;
theme: string;
variant: string;
viewport: {
height: number;
width: number;
};
visibilityState: string;
};
type LcpEntrySnapshot = {
context: LcpContextSnapshot;
element: LcpElementSnapshot | null;
loadTime: number;
renderTime: number;
resources: LcpResourceGroup[];
size: number;
startTime: number;
url: string;
};
export type WmLcpDebugState = {
enabled: true;
entries: LcpEntrySnapshot[];
getSnapshot: () => {
context: LcpContextSnapshot;
entries: LcpEntrySnapshot[];
marks: LcpMarkSnapshot[];
resources: LcpResourceGroup[];
};
marks: LcpMarkSnapshot[];
observerInstalled: boolean;
};
declare global {
interface Window {
__wmLcpDebug?: WmLcpDebugState;
}
}
const DEBUG_QUERY_PARAM = 'wm_lcp_debug';
const DEBUG_STORAGE_KEYS = ['wm_lcp_debug', 'wm-lcp-debug'];
// Raw LCP element text is opt-in via a SEPARATE, louder flag. The LCP element is
// frequently a content block (a headline, a personalized greeting, a selected
// place name), so its textContent can hold user/PII content. Attribution only
// needs structure (tag/selector/closest/size), so by default we capture the
// text *length* and leave the text itself redacted. (#4512 review)
const DEBUG_TEXT_QUERY_PARAM = 'wm_lcp_text';
const DEBUG_TEXT_STORAGE_KEYS = ['wm_lcp_text', 'wm-lcp-text'];
const MAX_ENTRIES = 20;
const MAX_RESOURCE_GROUPS = 12;
const MAX_TEXT_LENGTH = 140;
type LcpPerformanceEntry = PerformanceEntry & {
element?: Element;
loadTime?: number;
renderTime?: number;
size?: number;
url?: string;
};
function capText(value: string, maxLength = MAX_TEXT_LENGTH): string {
return value.replace(/\s+/g, ' ').trim().slice(0, maxLength);
}
function getWindowStorage(kind: 'localStorage' | 'sessionStorage'): Storage | undefined {
if (typeof window === 'undefined') return undefined;
try {
return window[kind];
} catch {
return undefined;
}
}
function getStorageFlag(storage: Storage | undefined, key: string): string | null {
try {
return storage?.getItem(key) ?? null;
} catch {
return null;
}
}
function isTruthyDebugFlag(value: string | null): boolean {
return value === '1' || value === 'true' || value === 'yes';
}
function isFlagEnabled(queryParam: string, storageKeys: string[]): boolean {
if (typeof window === 'undefined') return false;
try {
const params = new URL(window.location.href).searchParams;
if (isTruthyDebugFlag(params.get(queryParam))) return true;
} catch {
// Ignore URL parsing failures in unusual embedded runtimes.
}
const sessionStorage = getWindowStorage('sessionStorage');
const localStorage = getWindowStorage('localStorage');
for (const key of storageKeys) {
if (
isTruthyDebugFlag(getStorageFlag(sessionStorage, key))
|| isTruthyDebugFlag(getStorageFlag(localStorage, key))
) {
return true;
}
}
return false;
}
function isLcpDebugEnabled(): boolean {
return isFlagEnabled(DEBUG_QUERY_PARAM, DEBUG_STORAGE_KEYS);
}
function isLcpTextCaptureEnabled(): boolean {
return isFlagEnabled(DEBUG_TEXT_QUERY_PARAM, DEBUG_TEXT_STORAGE_KEYS);
}
function getClassName(element: Element): string {
const className = element.className;
if (typeof className === 'string') return className;
if (className && typeof className === 'object' && 'baseVal' in className) {
return String((className as SVGAnimatedString).baseVal ?? '');
}
return '';
}
function escapeClassName(name: string): string {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(name);
}
return name.replace(/[^a-zA-Z0-9_-]/g, (char) => `\\${char}`);
}
function buildSelector(element: Element): string {
const tag = element.tagName.toLowerCase();
const id = element.id ? `#${element.id}` : '';
const classes = getClassName(element)
.split(/\s+/)
.filter(Boolean)
.slice(0, 4)
.map((name) => `.${escapeClassName(name)}`)
.join('');
return `${tag}${id}${classes}`.slice(0, 180);
}
function closestAttributionLabel(element: Element): string {
const panel = element.closest<HTMLElement>('[data-panel-id]');
if (panel?.dataset.panelId) return `panel:${panel.dataset.panelId}`;
if (element.closest('[data-shell-lcp]')) return 'shell-lcp';
if (element.closest('.skeleton-shell')) return 'shell';
if (element.closest('#mapContainer')) return 'map-container';
if (element.closest('#mapSection')) return 'map-section';
if (element.closest('.map-renderer-shell')) return 'map-renderer-shell';
if (element.closest('.panel')) return 'panel';
return '';
}
function snapshotElement(element: Element | undefined): LcpElementSnapshot | null {
if (!element) return null;
const rawText = capText(element.textContent ?? '');
return {
className: capText(getClassName(element), 240),
closest: closestAttributionLabel(element),
id: element.id,
selector: buildSelector(element),
tagName: element.tagName.toLowerCase(),
text: isLcpTextCaptureEnabled() ? rawText : '',
textLength: rawText.length,
};
}
function classifyCriticalResource(name: string, initiatorType: string): string {
const lower = name.toLowerCase();
if (lower.includes('/api/bootstrap')) return 'bootstrap';
if (lower.includes('/api/news/v1/list-feed-digest')) return 'feed-digest';
if (lower.includes('/data/countries.geojson')) return 'country-geometry';
if (lower.includes('/data/countries-50m.json') || lower.includes('/data/countries-110m.json')) return 'map-topology';
if (
lower.includes('mapcontainer')
|| lower.includes('deckglmap')
|| lower.includes('globemap')
|| lower.includes('maplibre')
|| lower.includes('deck-stack')
|| lower.includes('protomaps')
) {
return 'map-chunk';
}
if (lower.includes('sentry') || lower.includes('clerk') || lower.includes('vercel') || lower.includes('analytics')) {
return 'secondary-startup';
}
if (initiatorType === 'script' || lower.endsWith('.js')) return 'script';
if (initiatorType === 'css' || lower.endsWith('.css')) return 'style';
if (lower.includes('/api/')) return 'api';
if (lower.includes('/assets/') || lower.includes('/data/')) return 'static';
return 'other';
}
function summarizeCriticalResources(upToStartTime = Number.POSITIVE_INFINITY): LcpResourceGroup[] {
if (typeof performance === 'undefined' || typeof performance.getEntriesByType !== 'function') return [];
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
const groups = new Map<string, LcpResourceGroup>();
for (const resource of resources) {
if (resource.startTime > upToStartTime + 1) continue;
const category = classifyCriticalResource(resource.name, resource.initiatorType);
if (category === 'other') continue;
const group = groups.get(category) ?? {
category,
count: 0,
encodedBodySize: 0,
transferSize: 0,
};
const transferSize = Math.max(0, resource.transferSize || 0);
group.count += 1;
group.encodedBodySize += Math.max(0, resource.encodedBodySize || 0);
group.transferSize += transferSize;
if (!group.largest || transferSize > group.largest.transferSize) {
group.largest = {
duration: Math.round(resource.duration),
initiatorType: resource.initiatorType,
name: sanitizeWebVitalUrl(resource.name),
startTime: Math.round(resource.startTime),
transferSize,
};
}
groups.set(category, group);
}
return Array.from(groups.values())
.sort((a, b) => b.transferSize - a.transferSize || b.encodedBodySize - a.encodedBodySize || a.category.localeCompare(b.category))
.slice(0, MAX_RESOURCE_GROUPS);
}
function snapshotContext(): LcpContextSnapshot {
const root = typeof document !== 'undefined' ? document.documentElement : null;
return {
devicePixelRatio: Math.round((typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1) * 100) / 100,
theme: root?.dataset.theme ?? '',
variant: root?.dataset.variant ?? '',
viewport: {
height: Math.round(typeof window !== 'undefined' ? window.innerHeight || root?.clientHeight || 0 : root?.clientHeight || 0),
width: Math.round(typeof window !== 'undefined' ? window.innerWidth || root?.clientWidth || 0 : root?.clientWidth || 0),
},
visibilityState: typeof document !== 'undefined' ? document.visibilityState : '',
};
}
function getOrCreateDebugState(): WmLcpDebugState {
const existing = window.__wmLcpDebug;
if (existing?.enabled) return existing;
const state: WmLcpDebugState = {
enabled: true,
entries: [],
getSnapshot: () => ({
context: snapshotContext(),
entries: state.entries.slice(),
marks: state.marks.slice(),
resources: summarizeCriticalResources(),
}),
marks: [],
observerInstalled: false,
};
window.__wmLcpDebug = state;
return state;
}
function pushCapped<T>(items: T[], item: T, cap: number): void {
items.push(item);
if (items.length > cap) items.splice(0, items.length - cap);
}
function recordLcpEntry(entry: LcpPerformanceEntry): void {
const state = window.__wmLcpDebug;
if (!state?.enabled) return;
pushCapped(state.entries, {
context: snapshotContext(),
element: snapshotElement(entry.element),
loadTime: Math.round(entry.loadTime ?? 0),
renderTime: Math.round(entry.renderTime ?? 0),
resources: summarizeCriticalResources(entry.startTime),
size: Math.round(entry.size ?? 0),
startTime: Math.round(entry.startTime),
url: sanitizeWebVitalUrl(entry.url),
}, MAX_ENTRIES);
}
export function installLcpAttributionDebug(): void {
if (typeof window === 'undefined' || !isLcpDebugEnabled()) return;
const state = getOrCreateDebugState();
if (state.observerInstalled) return;
state.observerInstalled = true;
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
recordLcpEntry(entry as LcpPerformanceEntry);
}
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
} catch {
markLcpDebug('wm:lcp-debug:observer-unavailable');
}
document.addEventListener('visibilitychange', () => {
markLcpDebug(`wm:visibility:${document.visibilityState}`);
}, { passive: true });
window.addEventListener('pagehide', () => {
markLcpDebug('wm:pagehide');
}, { passive: true });
markLcpDebug('wm:lcp-debug:installed');
}
export const __testing__ = {
capText,
classifyCriticalResource,
isLcpDebugEnabled,
isLcpTextCaptureEnabled,
isTruthyDebugFlag,
sanitizeResourceUrl: sanitizeWebVitalUrl,
snapshotContext,
};