/
kreyndelHam
/
Konstructorium
Обзор
Документация
Войти
/
kreyndelHam
/
Konstructorium
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
1
CI/CD
Аналитика
Безопасность
dev
frontend/src/services/webAnalyticsTracker.js
514 строк
13 KB
MihahamYT
Fixed errors
17 май 2026, 19:21
17 май 2026, 19:21
e1d82b6
Код
Авторство
О чём код?
/** * First-party web analytics tracker (sends events only when consent is granted). */ import { normalizeApiPath } from '../utils/normalizeApiPath' export { normalizeApiPath } const CONSENT_KEY = 'konstructorium_analytics_consent' const VISITOR_KEY = 'konstructorium_analytics_visitor_id' const SESSION_KEY = 'konstructorium_analytics_session_id' const SESSION_STARTED_KEY = 'konstructorium_analytics_session_started_at' const CONSENT_VERSION = '1.0' const SESSION_TIMEOUT_MS = 30 * 60 * 1000 const FLUSH_INTERVAL_MS = 5000 /** Must stay within analytics_service ANALYTICS_BATCH_MAX_EVENTS. */ const ANALYTICS_BATCH_CHUNK_SIZE = 25 const ANALYTICS_FLUSH_QUEUE_THRESHOLD = 20 const BATCH_ENDPOINT = '/api/analytics/events/batch/' const CONSENT_ENDPOINT = '/api/analytics/consent/' /** @type {Array<Record<string, unknown>>} */ let pendingEvents = [] let flushTimer = null let currentPath = '' let currentPathStartedAt = 0 let performanceSent = false let initialized = false /** * Read analytics consent state from localStorage. * * @returns {'pending'|'granted'|'denied'} */ export const getAnalyticsConsent = () => { const value = localStorage.getItem(CONSENT_KEY) if (value === 'granted' || value === 'denied') { return value } return 'pending' } /** * Persist analytics consent choice. * * @param {'granted'|'denied'} status */ export const setAnalyticsConsent = (status) => { localStorage.setItem(CONSENT_KEY, status) if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('konstructorium-analytics-consent', { detail: { status } })) } } /** * Generate UUID v4 string. * * @returns {string} */ const createUuid = () => { if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID() } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char) => { const random = Math.floor(Math.random() * 16) const value = char === 'x' ? random : (random & 0x3) | 0x8 return value.toString(16) }) } /** * Get or create persistent visitor id. * * @returns {string} */ const getVisitorId = () => { let visitorId = localStorage.getItem(VISITOR_KEY) if (!visitorId) { visitorId = createUuid() localStorage.setItem(VISITOR_KEY, visitorId) } return visitorId } /** * Get active session id, rotating after inactivity timeout. * * @returns {string} */ const getSessionId = () => { const now = Date.now() const startedAt = Number(localStorage.getItem(SESSION_STARTED_KEY) || 0) let sessionId = localStorage.getItem(SESSION_KEY) if (!sessionId || !startedAt || now - startedAt > SESSION_TIMEOUT_MS) { sessionId = createUuid() localStorage.setItem(SESSION_KEY, sessionId) } localStorage.setItem(SESSION_STARTED_KEY, String(now)) return sessionId } /** * Touch session activity timestamp. */ const touchSession = () => { localStorage.setItem(SESSION_STARTED_KEY, String(Date.now())) } /** * Parse UTM parameters from current location. * * @returns {Record<string, string>} */ const getUtmParams = () => { const params = new URLSearchParams(window.location.search) return { utm_source: params.get('utm_source') || '', utm_medium: params.get('utm_medium') || '', utm_campaign: params.get('utm_campaign') || '', utm_content: params.get('utm_content') || '', utm_term: params.get('utm_term') || '', } } /** * Detect screen size bucket. * * @returns {string} */ const getScreenClass = () => { const width = window.innerWidth if (width < 768) { return 'mobile' } if (width < 1024) { return 'tablet' } return 'desktop' } /** * Whether analytics tracking is allowed. * * @returns {boolean} */ export const isAnalyticsTrackingEnabled = () => getAnalyticsConsent() === 'granted' /** * Queue analytics event for batch flush. * * @param {Record<string, unknown>} event */ export const trackAnalyticsEvent = (event) => { if (!isAnalyticsTrackingEnabled()) { return } touchSession() pendingEvents.push({ occurred_at: new Date().toISOString(), locale: navigator.language || '', screen_class: getScreenClass(), referrer: document.referrer || '', is_admin_area: window.location.pathname.startsWith('/admin'), ...getUtmParams(), ...event, }) if (pendingEvents.length >= ANALYTICS_FLUSH_QUEUE_THRESHOLD) { flushAnalyticsEvents() return } if (!flushTimer) { flushTimer = window.setTimeout(() => { flushAnalyticsEvents() }, FLUSH_INTERVAL_MS) } } /** * POST one analytics batch chunk to analytics_service. * * @param {Array<Record<string, unknown>>} events * @returns {Promise<void>} */ const postAnalyticsBatchChunk = async (events) => { const body = JSON.stringify({ visitor_id: getVisitorId(), session_id: getSessionId(), consent_version: CONSENT_VERSION, events, }) const response = await fetch(BATCH_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, keepalive: true, credentials: 'omit', }) if (!response.ok) { const detail = await response.text().catch(() => '') throw new Error(`Analytics ingest HTTP ${response.status}: ${detail.slice(0, 200)}`) } } /** * Send queued events to analytics_service. * * @returns {Promise<void>} */ export const flushAnalyticsEvents = async () => { if (flushTimer) { clearTimeout(flushTimer) flushTimer = null } if (!isAnalyticsTrackingEnabled() || pendingEvents.length === 0) { pendingEvents = [] return } const events = [...pendingEvents] pendingEvents = [] try { for (let offset = 0; offset < events.length; offset += ANALYTICS_BATCH_CHUNK_SIZE) { const chunk = events.slice(offset, offset + ANALYTICS_BATCH_CHUNK_SIZE) await postAnalyticsBatchChunk(chunk) } } catch (error) { console.warn('Analytics flush failed:', error) pendingEvents = [...events, ...pendingEvents] } } /** * Record consent server-side and initialize tracker when granted. * * @param {boolean} granted */ export const recordAnalyticsConsent = async (granted) => { setAnalyticsConsent(granted ? 'granted' : 'denied') try { await fetch(CONSENT_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ visitor_id: getVisitorId(), session_id: getSessionId(), consent_version: CONSENT_VERSION, granted, }), credentials: 'omit', }) } catch (error) { console.warn('Failed to record analytics consent:', error) } if (granted) { initWebAnalyticsTracker() const path = `${window.location.pathname}${window.location.search}` trackPageView(path, null) flushAnalyticsEvents() } } /** * Track SPA route change as page view / leave pair. * * @param {string} path * @param {number|null} userId */ export const trackPageView = (path, userId = null) => { if (!isAnalyticsTrackingEnabled()) { return } const now = Date.now() if (currentPath) { trackAnalyticsEvent({ event_type: 'page_leave', path: currentPath, duration_ms: Math.max(now - currentPathStartedAt, 0), user_id: userId, }) } currentPath = path currentPathStartedAt = now trackAnalyticsEvent({ event_type: 'page_view', path, user_id: userId, query_keys: Array.from(new URLSearchParams(window.location.search).keys()).join(','), }) } /** * Track API request result from axios interceptor. * * @param {{method: string, url: string, status: number, durationMs: number, userId?: number|null}} payload */ export const trackApiRequest = ({ method, url, status, durationMs, userId = null }) => { const { path, apiService } = normalizeApiPath(url) trackAnalyticsEvent({ event_type: 'api_request', path, method: (method || 'GET').toUpperCase(), api_service: apiService, status_code: status, duration_ms: durationMs, success: status >= 200 && status < 400, user_id: userId, }) } /** * Track client-side runtime error. * * @param {{name?: string, message?: string, path?: string}} payload */ export const trackClientError = ({ name, message, path }) => { trackAnalyticsEvent({ event_type: 'client_error', path: path || window.location.pathname, error_name: name || 'Error', error_message: (message || '').slice(0, 500), }) } /** * Send operational user_error event immediately (does not require analytics consent). * * @param {{ * incidentId: string, * errorCode: string, * userMessage: string, * path?: string, * source?: string, * statusCode?: number|null, * apiService?: string, * userId?: number|null, * }} payload */ export const trackUserError = (payload) => { touchSession() const event = { occurred_at: new Date().toISOString(), event_type: 'user_error', path: (payload.path || window.location.pathname).slice(0, 512), incident_id: payload.incidentId, error_code: payload.errorCode, error_name: payload.errorCode, error_message: (payload.userMessage || '').slice(0, 500), user_id: payload.userId ?? null, status_code: payload.statusCode ?? undefined, api_service: payload.apiService || '', locale: navigator.language || '', screen_class: getScreenClass(), is_admin_area: window.location.pathname.startsWith('/admin'), payload: { source: payload.source || 'api', user_message: (payload.userMessage || '').slice(0, 200), }, } const body = JSON.stringify({ visitor_id: getVisitorId(), session_id: getSessionId(), consent_version: CONSENT_VERSION, events: [event], }) try { if (navigator.sendBeacon) { const blob = new Blob([body], { type: 'application/json' }) if (navigator.sendBeacon(BATCH_ENDPOINT, blob)) { return } } fetch(BATCH_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, keepalive: true, credentials: 'omit', }).catch((sendError) => { console.warn('Failed to send user_error event:', sendError) }) } catch (sendError) { console.warn('Failed to send user_error event:', sendError) } } /** * Send navigation performance metrics once per session. */ const trackPerformanceOnce = () => { if (performanceSent || !isAnalyticsTrackingEnabled()) { return } const [entry] = performance.getEntriesByType('navigation') if (!entry) { return } performanceSent = true trackAnalyticsEvent({ event_type: 'performance', path: window.location.pathname, payload: { ttfb_ms: Math.round(entry.responseStart), dom_content_loaded_ms: Math.round(entry.domContentLoadedEventEnd), load_ms: Math.round(entry.loadEventEnd), }, }) } let operationalHandlersRegistered = false /** * Register global user-error handlers (always active, independent of analytics consent). */ export const initOperationalErrorHandlers = () => { if (operationalHandlersRegistered || typeof window === 'undefined') { return } operationalHandlersRegistered = true const reportRuntimeError = async (runtimeError) => { const { showUserError: reportError } = await import('../utils/showUserError') reportError(runtimeError, { source: 'client' }) } window.addEventListener('error', (event) => { trackClientError({ name: event.error?.name || 'Error', message: event.message, path: window.location.pathname, }) reportRuntimeError(event.error || new Error(event.message || 'Unknown error')) }) window.addEventListener('unhandledrejection', (event) => { const reason = event.reason trackClientError({ name: reason?.name || 'UnhandledPromiseRejection', message: typeof reason === 'string' ? reason : reason?.message, path: window.location.pathname, }) reportRuntimeError(reason instanceof Error ? reason : new Error(typeof reason === 'string' ? reason : 'Unhandled rejection')) }) } /** * Register global error handlers and visibility flush. */ const registerGlobalHandlers = () => { initOperationalErrorHandlers() document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { if (currentPath) { trackAnalyticsEvent({ event_type: 'page_leave', path: currentPath, duration_ms: Math.max(Date.now() - currentPathStartedAt, 0), }) } flushAnalyticsEvents() } }) window.addEventListener('pagehide', () => { flushAnalyticsEvents() }) document.addEventListener('click', (event) => { const target = event.target if (!(target instanceof Element)) { return } const tracked = target.closest('[data-analytics]') if (!tracked) { return } trackAnalyticsEvent({ event_type: 'click', path: window.location.pathname, payload: { label: tracked.getAttribute('data-analytics') || '', }, }) }) } /** * Initialize tracker globals (idempotent). */ export const initWebAnalyticsTracker = () => { if (initialized || !isAnalyticsTrackingEnabled()) { return } initialized = true registerGlobalHandlers() trackPerformanceOnce() }