SDK de monitoreo frontend completo para entornos de producción

Arquitectura y estructura del proyecto

Este SDK proporciona capacidades completas de monitoreo frontend, incluyendo procesameinto en Web Workers, garantía de fiabilidad de datos y supervisión multidimensional. La arquitectura se divide en módulos específicos para manejar la recolección, procesamiento y reporte de métricas de manera eficiente.

// Estructura de directorios propuesta
frontend-monitor-sdk/
├── src/
│   ├── kernel/                // Núcleo del SDK
│   │   ├── main.js            // Punto de entrada principal
│   │   ├── settings.js        // Gestión de configuración
│   │   └── workers/           // Módulos de Web Workers
│   ├── collectors/            // Módulos de recolección de datos
│   │   ├── base-collector.js  // Clase base para recolectores
│   │   ├── errors.js          // Supervisión de errores
│   │   ├── metrics.js         // Métricas de rendimiento
│   │   ├── interactions.js    // Interacciones de usuario
│   │   ├── views.js           // Monitoreo de visualizaciones
│   │   └── assets.js          // Carga de recursos
│   ├── dispatchers/           // Módulos de envío de datos
│   │   ├── dispatcher.js      // Estrategia de reporte
│   │   └── persistence.js     // Almacenamiento local
│   ├── helpers/               // Funciones de utilidad
│   │   ├── anonymizer.js      // Anonimización de datos
│   │   ├── connectivity.js    // Detección de red
│   │   └── dom-utils.js       // Manipulación del DOM
│   └── typings/               // Definiciones de tipos
└── build/                     // Salida de compilación

Implementación del módulo principle

El punto de entrada principal inicializa los componentes esenciales y gestiona el ciclo de vida del SDK.

import { SettingsManager } from './settings.js';
import { WorkerController } from './workers/controller.js';
import { startErrorCollecting } from '../collectors/errors.js';
import { startMetricsCollecting } from '../collectors/metrics.js';
import { startInteractionCollecting } from '../collectors/interactions.js';
import { startViewCollecting } from '../collectors/views.js';
import { DataDispatcher } from '../dispatchers/dispatcher.js';

class ProductionMonitor {
  constructor() {
    this.settings = new SettingsManager();
    this.workerController = null;
    this.dispatcher = null;
    this.active = false;
  }

  async initialize(userOptions = {}) {
    if (this.active) return;

    try {
      await this.settings.applyOptions(userOptions);
      
      this.workerController = new WorkerController(this.settings);
      await this.workerController.setup();
      
      this.dispatcher = new DataDispatcher(this.workerController, this.settings);
      
      this.registerCollectors();
      this.setupUnloadHandlers();
      
      this.active = true;
      console.log('Production Monitor SDK activado');
    } catch (err) {
      console.error('Fallo al activar el SDK:', err);
    }
  }

  registerCollectors() {
    const { workerController, settings } = this;
    
    startErrorCollecting(workerController, settings);
    startMetricsCollecting(workerController, settings);
    startInteractionCollecting(workerController, settings);
    
    if (settings.get('trackViews')) {
      startViewCollecting(workerController, settings);
    }
  }

  setupUnloadHandlers() {
    window.addEventListener('beforeunload', () => {
      this.dispatcher.immediateSend();
    });

    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this.dispatcher.sendPendingData();
      }
    });
  }

  logEvent(eventName, payload) {
    if (!this.active) return;
    
    this.workerController.postMessage({
      category: 'USER_EVENT',
      content: {
        eventName,
        ...payload,
        time: Date.now()
      }
    });
  }

  identifyUser(userData) {
    if (!this.active) return;
    
    this.workerController.postMessage({
      category: 'IDENTIFY_USER',
      content: userData
    });
  }
}

const monitor = new ProductionMonitor();

if (globalThis.__MONITOR_OPTIONS__) {
  monitor.initialize(globalThis.__MONITOR_OPTIONS__);
}

export { monitor as default };

Gestión de configuración

El sistema de configuración maneja valores predteerminados y permite actualizaciones dinámicas.

export class SettingsManager {
  constructor() {
    this.defaults = {
      endpoint: '',
      projectId: '',
      sdkVersion: '1.2.0',
      sampling: 0.15,
      errorCapture: true,
      metricsCapture: true,
      interactionCapture: true,
      viewCapture: false,
      resourceCapture: true,
      bufferLimit: 1500,
      batchSend: 60,
      cycleInterval: 25000,
      attempts: 4,
      backoff: 1200,
      anonymization: {
        active: true,
        fields: ['phoneNumber', 'nationalId', 'emailAddress']
      },
      routing: {
        active: true,
        delay: 800
      }
    };
    
    this.current = { ...this.defaults };
  }

  async applyOptions(userOptions) {
    this.current = { ...this.defaults, ...userOptions };
    
    try {
      const remote = await this.loadRemoteSettings();
      this.current = { ...this.current, ...remote };
    } catch (e) {
      console.warn('Configuración remota no disponible:', e);
    }
    
    return this.current;
  }

  async loadRemoteSettings() {
    const { projectId, endpoint } = this.current;
    if (!projectId || !endpoint) return {};
    
    const res = await fetch(`${endpoint}/settings`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ projectId, version: this.current.sdkVersion })
    });
    
    if (!res.ok) throw new Error('Fallo en carga de configuración');
    
    return await res.json();
  }

  get(key) {
    return key ? this.current[key] : this.current;
  }

  update(key, value) {
    this.current[key] = value;
  }
}

Control de Web Workers

El controlador de Workers maneja la comunicación y proporciona una alternativa de fallback en el hilo principal.

import { DataProcessor } from './processor.js';

export class WorkerController {
  constructor(settings) {
    this.settings = settings;
    this.worker = null;
    this.dataQueue = [];
    this.ready = false;
  }

  async setup() {
    if (typeof Worker === 'undefined') {
      this.processor = new DataProcessor(this.settings);
      await this.processor.setup();
    } else {
      this.worker = new Worker('./worker-bundle.js');
      this.configureMessaging();
    }
    
    this.ready = true;
  }

  configureMessaging() {
    this.worker.onmessage = (evt) => {
      const { action, payload } = evt.data;
      
      if (action === 'QUEUE_SYNC') {
        this.dataQueue = payload.queue;
      } else if (action === 'SEND_STATUS') {
        this.handleSendStatus(payload);
      }
    };
  }

  postMessage(msg) {
    if (!this.ready) {
      console.warn('Controlador no inicializado');
      return;
    }

    if (this.worker) {
      this.worker.postMessage(msg);
    } else {
      this.processOnMainThread(msg);
    }
  }

  async processOnMainThread(msg) {
    try {
      const processed = await this.processor.handle(msg);
      if (processed) {
        this.dataQueue.push(processed);
        
        if (this.dataQueue.length >= this.settings.get('batchSend')) {
          await this.flushData();
        }
      }
    } catch (e) {
      console.error('Error en procesamiento principal:', e);
    }
  }

  async flushData() {
    if (this.worker) {
      this.worker.postMessage({ action: 'FLUSH' });
    } else {
      await this.processor.send(this.dataQueue);
      this.dataQueue = [];
    }
  }

  handleSendStatus(info) {
    const { success, failed } = info;
    
    if (!success && failed > 0) {
      console.warn(`Envío fallido, ${failed} registros pendientes`);
    }
  }
}

Lógica principal del Worker

El Worker gestiona cola de datos, almacenamiento local y envíos programados.

importScripts('./processor.js', '../storage/indexdb-storage.js');

let queue = [];
let sending = false;
let processor;
let storage;

self.onmessage = async (evt) => {
  const { action, content } = evt.data;

  switch (action) {
    case 'INIT':
      await initialize(content.settings);
      break;
    case 'USER_EVENT':
    case 'ERROR':
    case 'METRIC':
    case 'INTERACTION':
    case 'VIEW':
      await processEntry(action, content);
      break;
    case 'FLUSH':
      await flushQueue();
      break;
    case 'IDENTIFY_USER':
      setUserContext(content);
      break;
  }
};

async function initialize(settings) {
  processor = new DataProcessor(settings);
  storage = new IndexDBStorage(settings);
  
  const stored = await storage.retrieveQueue();
  queue = [...queue, ...stored];
  
  self.postMessage({
    action: 'QUEUE_SYNC',
    payload: { queue: queue.length }
  });
  
  setInterval(() => {
    if (queue.length > 0) {
      flushQueue();
    }
  }, settings.cycleInterval || 25000);
}

async function processEntry(category, data) {
  try {
    const entry = await processor.transform({
      category,
      content: data,
      timestamp: Date.now()
    });
    
    if (entry) {
      queue.push(entry);
      
      if (queue.length > (processor.settings.bufferLimit || 1500)) {
        await persistQueue();
        queue = [];
      }
      
      self.postMessage({
        action: 'QUEUE_SYNC',
        payload: { queue: queue.length }
      });
    }
  } catch (e) {
    console.error('Fallo en procesamiento:', e);
  }
}

async function flushQueue() {
  if (sending || queue.length === 0) return;
  
  sending = true;
  
  try {
    const success = await processor.send(queue);
    
    self.postMessage({
      action: 'SEND_STATUS',
      payload: {
        success,
        failed: success ? 0 : queue.length
      }
    });
    
    if (success) {
      queue = [];
      await storage.clear();
    } else {
      await persistQueue();
    }
  } catch (e) {
    console.error('Fallo en envío:', e);
    await persistQueue();
  } finally {
    sending = false;
  }
}

async function persistQueue() {
  if (queue.length > 0) {
    await storage.saveQueue(queue);
  }
}

function setUserContext(userData) {
  processor.setUserContext(userData);
}

Recolector de errores

Este módulo captura excepciones JavaScript, errores de recursos y fallos en frameworks.

import { registerLog } from './base-collector.js';

export function startErrorCollecting(controller, settings) {
  if (!settings.get('errorCapture')) return;

  window.addEventListener('error', (evt) => {
    logError('RUNTIME', {
      text: evt.message,
      file: evt.filename,
      line: evt.lineno,
      column: evt.colno,
      error: serializeError(evt.error)
    }, controller);
  });

  window.addEventListener('unhandledrejection', (evt) => {
    logError('PROMISE', {
      text: evt.reason?.message || 'Rechazo no manejado',
      details: serializeError(evt.reason)
    }, controller);
  });

  window.addEventListener('error', (evt) => {
    const target = evt.target;
    if (target !== window && target.nodeType === 1) {
      logError('ASSET', {
        tag: target.tagName,
        source: target.src || target.href,
        identifier: target.id,
        classes: target.className
      }, controller);
    }
  }, true);

  replaceConsoleError(controller);
}

function logError(kind, errorInfo, controller) {
  registerLog(controller, {
    category: 'ERROR',
    content: {
      errorKind: kind,
      ...errorInfo,
      pageUrl: window.location.href,
      agent: navigator.userAgent,
      time: Date.now()
    }
  });
}

function serializeError(err) {
  if (!err) return null;
  
  return {
    name: err.name,
    message: err.message,
    stack: err.stack
  };
}

function replaceConsoleError(controller) {
  const originalError = console.error;
  
  console.error = function(...args) {
    registerLog(controller, {
      category: 'ERROR',
      content: {
        errorKind: 'CONSOLE',
        messages: args.map(a => 
          typeof a === 'object' ? serializeError(a) : String(a)
        ),
        time: Date.now()
      }
    });
    
    originalError.apply(console, args);
  };
}

export function setupVueErrorHandling(Vue, controller) {
  Vue.config.errorHandler = (err, vm, info) => {
    registerLog(controller, {
      category: 'ERROR',
      content: {
        errorKind: 'VUE',
        message: err.message,
        stack: err.stack,
        component: vm?.$options?.name,
        hook: info,
        time: Date.now()
      }
    });
  };
}

export class ReactErrorCatcher extends React.Component {
  constructor(props) {
    super(props);
    this.state = { error: false };
  }

  static getDerivedStateFromError() {
    return { error: true };
  }

  componentDidCatch(error, info) {
    registerLog(this.props.controller, {
      category: 'ERROR',
      content: {
        errorKind: 'REACT',
        message: error.message,
        stack: error.stack,
        componentStack: info.componentStack,
        time: Date.now()
      }
    });
  }

  render() {
    if (this.state.error) {
      return this.props.fallback || <div>Error detectado.</div>;
    }
    return this.props.children;
  }
}

Recolector de métricas de rendimiento

Supervisa indicadores clave de rendimiento, recursos cargados y tareas largas.

import { registerLog } from './base-collector.js';

export function startMetricsCollecting(controller, settings) {
  if (!settings.get('metricsCapture')) return;

  collectWebVitals(controller);
  observeResources(controller);
  
  if (settings.get('routing.active')) {
    monitorRouting(controller, settings);
  }
  
  trackLongTasks(controller);
}

function collectWebVitals(controller) {
  if (typeof getCLS === 'function') {
    getCLS(reportVital('CLS', controller));
    getFID(reportVital('FID', controller));
    getFCP(reportVital('FCP', controller));
    getLCP(reportVital('LCP', controller));
    getTTFB(reportVital('TTFB', controller));
  } else {
    manualPerformanceCalculation(controller);
  }
}

function reportVital(name, controller) {
  return (vital) => {
    registerLog(controller, {
      category: 'METRIC',
      content: {
        metricKind: 'WEB_VITAL',
        name,
        value: vital.value,
        score: vital.rating,
        change: vital.delta,
        time: Date.now()
      }
    });
  };
}

function manualPerformanceCalculation(controller) {
  window.addEventListener('load', () => {
    setTimeout(() => {
      const timing = performance.timing;
      const navigationEntry = performance.getEntriesByType('navigation')[0];
      
      const data = {
        redirect: timing.redirectEnd - timing.redirectStart,
        dns: timing.domainLookupEnd - timing.domainLookupStart,
        tcp: timing.connectEnd - timing.connectStart,
        ttfb: timing.responseStart - timing.requestStart,
        response: timing.responseEnd - timing.responseStart,
        domParse: timing.domComplete - timing.domInteractive,
        domReady: timing.domContentLoadedEventEnd - timing.navigationStart,
        fullLoad: timing.loadEventEnd - timing.navigationStart,
        
        fp: getFirstPaintTime(),
        fcp: getFirstContentfulPaintTime(),
        lcp: getLargestContentfulPaintTime()
      };
      
      registerLog(controller, {
        category: 'METRIC',
        content: {
          metricKind: 'PAGE_LOAD',
          ...data,
          time: Date.now()
        }
      });
    }, 0);
  });
}

function observeResources(controller) {
  const observer = new PerformanceObserver((list) => {
    list.getEntries().forEach((entry) => {
      if (entry.entryType === 'resource') {
        registerLog(controller, {
          category: 'METRIC',
          content: {
            metricKind: 'RESOURCE',
            url: entry.name,
            duration: entry.duration,
            size: entry.transferSize,
            type: classifyResource(entry.name),
            time: Date.now()
          }
        });
      }
    });
  });
  
  observer.observe({ entryTypes: ['resource'] });
}

function classifyResource(url) {
  if (url.includes('.css')) return 'stylesheet';
  if (url.includes('.js')) return 'script';
  if (url.match(/\.(jpg|jpeg|png|gif|webp|svg)/i)) return 'image';
  if (url.includes('/api/') || url.includes('/graphql')) return 'request';
  return 'other';
}

function trackLongTasks(controller) {
  if (!PerformanceObserver) return;
  
  const observer = new PerformanceObserver((list) => {
    list.getEntries().forEach((entry) => {
      if (entry.duration > 50) {
        registerLog(controller, {
          category: 'METRIC',
          content: {
            metricKind: 'LONG_TASK',
            duration: entry.duration,
            start: entry.startTime,
            attribution: entry.attribution,
            time: Date.now()
          }
        });
      }
    });
  });
  
  try {
    observer.observe({ entryTypes: ['longtask'] });
  } catch (e) {
    // Navegador no soporta longtask
  }
}

function monitorRouting(controller, settings) {
  let currentPath = getCurrentPath();
  let routeStart = Date.now();
  
  const onRouteChange = () => {
    const newPath = getCurrentPath();
    if (newPath === currentPath) return;
    
    const stayDuration = Date.now() - routeStart;
    
    registerLog(controller, {
      category: 'METRIC',
      content: {
        metricKind: 'ROUTE_STAY',
        from: currentPath,
        to: newPath,
        duration: stayDuration,
        time: Date.now()
      }
    });
    
    currentPath = newPath;
    routeStart = Date.now();
    
    setTimeout(() => {
      captureRouteMetrics(newPath, controller);
    }, settings.get('routing.delay'));
  };
  
  window.addEventListener('hashchange', onRouteChange);
  window.addEventListener('popstate', onRouteChange);
  
  extendHistoryMethods(onRouteChange);
}

function getCurrentPath() {
  return window.location.pathname + window.location.search + window.location.hash;
}

function extendHistoryMethods(callback) {
  const push = history.pushState;
  const replace = history.replaceState;
  
  history.pushState = function(...args) {
    push.apply(this, args);
    callback();
  };
  
  history.replaceState = function(...args) {
    replace.apply(this, args);
    callback();
  };
}

function captureRouteMetrics(path, controller) {
  const metrics = {
    route: path,
    fp: getFirstPaintTime(),
    fcp: getFirstContentfulPaintTime(),
    ready: Date.now() - performance.timing.navigationStart
  };
  
  registerLog(controller, {
    category: 'METRIC',
    content: {
      metricKind: 'ROUTE_LOAD',
      ...metrics,
      time: Date.now()
    }
  });
}

Recolector de interacciones de usuario

Captura eventos de clic, tiempo de permanencia, desplazamiento y cambios de visibilidad.

import { registerLog } from './base-collector.js';

export function startInteractionCollecting(controller, settings) {
  if (!settings.get('interactionCapture')) return;

  trackClicks(controller);
  trackStayDuration(controller);
  trackScrolling(controller);
  trackVisibilityChanges(controller);
}

function trackClicks(controller) {
  document.addEventListener('click', (evt) => {
    const target = evt.target;
    const clickInfo = {
      posX: evt.clientX,
      posY: evt.clientY,
      element: {
        tag: target.tagName,
        id: target.id,
        classes: target.className,
        text: extractText(target),
        path: buildSelectorPath(target)
      },
      time: Date.now()
    };
    
    // Control de muestreo
    if (Math.random() < 0.1) { // 10% de muestreo
      registerLog(controller, {
        category: 'INTERACTION',
        content: {
          action: 'CLICK',
          ...clickInfo
        }
      });
    }
  }, { capture: true });
}

function trackStayDuration(controller) {
  let pageStart = Date.now();
  let visibleStart = Date.now();
  
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
      const visibleDuration = Date.now() - visibleStart;
      
      registerLog(controller, {
        category: 'INTERACTION',
        content: {
          action: 'VISIBLE_DURATION',
          duration: visibleDuration,
          time: Date.now()
        }
      });
    } else {
      visibleStart = Date.now();
    }
  });
  
  window.addEventListener('beforeunload', () => {
    const totalDuration = Date.now() - pageStart;
    
    registerLog(controller, {
      category: 'INTERACTION',
      content: {
        action: 'PAGE_DURATION',
        duration: totalDuration,
        time: Date.now()
      }
    });
  });
}

function trackScrolling(controller) {
  let depthRecorded = false;
  
  window.addEventListener('scroll', throttle(() => {
    const depth = calculateScrollDepth();
    
    if (depth > 0.8 && !depthRecorded) {
      registerLog(controller, {
        category: 'INTERACTION',
        content: {
          action: 'SCROLL_DEPTH',
          depth,
          time: Date.now()
        }
      });
      depthRecorded = true;
    }
  }, 1000));
}

function calculateScrollDepth() {
  const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
  const scrollHeight = document.documentElement.scrollHeight;
  const clientHeight = window.innerHeight;
  
  return scrollTop / (scrollHeight - clientHeight);
}

function trackVisibilityChanges(controller) {
  let changes = 0;
  
  document.addEventListener('visibilitychange', () => {
    changes++;
    
    registerLog(controller, {
      category: 'INTERACTION',
      content: {
        action: 'VISIBILITY_CHANGE',
        state: document.visibilityState,
        count: changes,
        time: Date.now()
      }
    });
  });
}

// Funciones de utilidad
function extractText(element) {
  return element.textContent?.trim().slice(0, 50) || '';
}

function buildSelectorPath(element) {
  const parts = [];
  while (element && element.nodeType === Node.ELEMENT_NODE) {
    let selector = element.nodeName.toLowerCase();
    
    if (element.id) {
      selector += `#${element.id}`;
      parts.unshift(selector);
      break;
    } else {
      let sibling = element;
      let nth = 1;
      while (sibling = sibling.previousElementSibling) {
        if (sibling.nodeName.toLowerCase() === selector) nth++;
      }
      if (nth !== 1) selector += `:nth-of-type(${nth})`;
    }
    
    parts.unshift(selector);
    element = element.parentNode;
  }
  
  return parts.join(' > ');
}

function throttle(func, delay) {
  let timeoutId;
  let lastRun = 0;
  
  return function(...args) {
    const now = Date.now();
    
    if (now - lastRun > delay) {
      func.apply(this, args);
      lastRun = now;
    } else {
      clearTimeout(timeoutId);
      timeoutId = setTimeout(() => {
        func.apply(this, args);
        lastRun = Date.now();
      }, delay - (now - lastRun));
    }
  };
}

Herramientas de anonimización y cumplimiento

Provee funcionalidades para proteger datos sensibles y cumplir con regulaciones de privacidad.

// Enmascaramiento de información sensible
export const maskValue = (value, category = 'auto') => {
  if (typeof value !== 'string') return value;
  
  const patterns = {
    phone: /(\d{3})\d{4}(\d{4})/,
    id: /(\d{6})\d{8}(\w{4})/,
    email: /(\w{3})(\w+)(@\w+\.\w+)/,
    card: /(\d{4})\d{8,12}(\d{4})/,
    auto: /\b(\d{3})\d{4}(\d{4})\b|\b(\d{6})\d{8}(\w{4})\b|\b(\w{3})(\w+)(@\w+\.\w+)\b/
  };
  
  const regex = patterns[category] || patterns.auto;
  
  return value.replace(regex, (match, ...groups) => {
    if (category === 'phone') {
      return `${groups[0]}****${groups[1]}`;
    } else if (category === 'id') {
      return `${groups[0]}********${groups[1]}`;
    } else if (category === 'email') {
      return `${groups[0]}***${groups[2]}`;
    } else if (category === 'card') {
      return `${groups[0]}${'*'.repeat(match.length - 8)}${groups[1]}`;
    }
    
    // Auto-detección
    if (match.length === 11 && /^1[3-9]\d{9}$/.test(match)) {
      return `${match.slice(0, 3)}****${match.slice(7)}`;
    } else if (match.length === 18 && /^\d{17}[\dXx]$/.test(match)) {
      return `${match.slice(0, 6)}********${match.slice(14)}`;
    } else if (match.includes('@')) {
      const [local, domain] = match.split('@');
      return `${local.slice(0, 3)}***@${domain}`;
    }
    
    return match;
  });
};

// Enmascaramiento profundo de objetos
export const deepMask = (obj, sensitiveKeys = []) => {
  if (typeof obj !== 'object' || obj === null) {
    return typeof obj === 'string' ? maskValue(obj) : obj;
  }
  
  if (Array.isArray(obj)) {
    return obj.map(item => deepMask(item, sensitiveKeys));
  }
  
  const result = {};
  const defaultKeys = ['phone', 'mobile', 'idCard', 'identity', 'email', 'card', 'password'];
  const allSensitive = [...defaultKeys, ...sensitiveKeys];
  
  for (const [key, value] of Object.entries(obj)) {
    const lowerKey = key.toLowerCase();
    
    const isSensitive = allSensitive.some(k => 
      lowerKey.includes(k.toLowerCase())
    );
    
    if (isSensitive && typeof value === 'string') {
      result[key] = maskValue(value);
    } else {
      result[key] = deepMask(value, sensitiveKeys);
    }
  }
  
  return result;
};

// Gestión de consentimiento de privacidad
export class PrivacyManager {
  constructor() {
    this.consent = false;
    this.required = false;
    this.checkRequirements();
  }
  
  checkRequirements() {
    const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
    const euZones = [
      'Europe/', 'CET', 'EET', 'WET', 'BST', 'CEST', 'EEST', 'WEST'
    ];
    
    this.required = euZones.some(tz => zone.includes(tz));
    
    if (this.required) {
      this.displayConsentBanner();
    } else {
      this.consent = true;
    }
  }
  
  displayConsentBanner() {
    console.log('Se requiere consentimiento para su región');
    
    setTimeout(() => {
      this.grantConsent();
    }, 2000);
  }
  
  grantConsent() {
    this.consent = true;
    if (this.onConsent) {
      this.onConsent();
    }
  }
  
  revokeConsent() {
    this.consent = false;
    this.clearStoredData();
  }
  
  clearStoredData() {
    if (window.indexedDB) {
      const req = indexedDB.deleteDatabase('MONITOR_DATA_DB');
      req.onsuccess = () => {
        console.log('Datos de monitoreo eliminados por cumplimiento');
      };
    }
    
    localStorage.removeItem('monitor_user_id');
    sessionStorage.removeItem('monitor_session');
  }
  
  setConsentHandler(handler) {
    this.onConsent = handler;
  }
}

Despachador de datos

Maneja el envío de datos con reintentos, adaptación a red y almacenamiento persistente.

import { StorageHandler } from './persistence.js';
import { NetworkMonitor } from '../helpers/connectivity.js';

export class DataDispatcher {
  constructor(controller, settings) {
    this.controller = controller;
    this.settings = settings;
    this.storage = new StorageHandler(settings);
    this.network = new NetworkMonitor();
    this.online = navigator.onLine;
    this.sending = false;
    
    this.setupNetworkListeners();
  }
  
  setupNetworkListeners() {
    window.addEventListener('online', () => {
      this.online = true;
      this.sendPendingData();
    });
    
    window.addEventListener('offline', () => {
      this.online = false;
    });
    
    this.network.onQualityChange((connection) => {
      this.adjustStrategy(connection);
    });
  }
  
  adjustStrategy(connection) {
    const { effectiveType, downlink, rtt } = connection;
    
    if (effectiveType === '4g' && downlink > 5) {
      this.settings.update('batchSend', 60);
      this.settings.update('cycleInterval', 25000);
    } else if (effectiveType === '3g' || downlink < 1) {
      this.settings.update('batchSend', 15);
      this.settings.update('cycleInterval', 45000);
    } else {
      this.settings.update('batchSend', 8);
      this.settings.update('cycleInterval', 90000);
    }
  }
  
  async sendPendingData() {
    if (!this.online || this.sending) return;
    
    try {
      this.sending = true;
      
      const pending = await this.storage.getPending();
      if (pending.length > 0) {
        await this.transmitBatch(pending);
        await this.storage.clearPending();
      }
    } catch (e) {
      console.error('Fallo en envío pendiente:', e);
    } finally {
      this.sending = false;
    }
  }
  
  async transmitBatch(data, attempt = 0) {
    if (data.length === 0) return true;
    
    const maxAttempts = this.settings.get('attempts');
    const endpoint = this.settings.get('endpoint');
    
    try {
      const response = await fetch(endpoint, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Project-Id': this.settings.get('projectId'),
          'X-SDK-Version': this.settings.get('sdkVersion')
        },
        body: JSON.stringify({
          events: data,
          timestamp: Date.now(),
          session: this.getSessionIdentifier()
        }),
        keepalive: true
      });
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      
      console.log(`Enviados ${data.length} registros`);
      return true;
    } catch (e) {
      console.error(`Fallo en envío (intento ${attempt + 1}):`, e);
      
      if (attempt < maxAttempts) {
        const delay = this.settings.get('backoff') * Math.pow(2, attempt);
        await new Promise(r => setTimeout(r, delay));
        return this.transmitBatch(data, attempt + 1);
      } else {
        await this.storage.savePending(data);
        return false;
      }
    }
  }
  
  async sendPendingData() {
    if (this.controller) {
      this.controller.flushData();
    } else {
      await this.sendPendingData();
    }
  }
  
  async immediateSend() {
    if (!navigator.sendBeacon) {
      await this.sendPendingData();
      return;
    }
    
    const pending = await this.storage.getPending();
    if (pending.length > 0) {
      const payload = JSON.stringify({
        events: pending,
        timestamp: Date.now(),
        session: this.getSessionIdentifier()
      });
      
      navigator.sendBeacon(this.settings.get('endpoint'), payload);
    }
  }
  
  getSessionIdentifier() {
    let id = sessionStorage.getItem('monitor_session_id');
    if (!id) {
      id = createUniqueId();
      sessionStorage.setItem('monitor_session_id', id);
    }
    return id;
  }
}

function createUniqueId() {
  return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}

Ejemplos de integración y uso

Implementación básica en HTML y ejemplos para frameworks populares.

<!-- Implementación HTML básica -->
<html>
<head>
  <title>Aplicación de ejemplo</title>
  <script>
    globalThis.__MONITOR_OPTIONS__ = {
      projectId: 'ejemplo-123',
      endpoint: 'https://monitor.example.com/collect',
      sampling: 0.1,
      viewCapture: true
    };
  </script>
  <script src="https://cdn.example.com/monitor-sdk.js"></script>
</head>
<body>
  <div data-track-view="banner-principal">Contenido</div>
  
  <script>
    monitor.initialize({
      projectId: 'ejemplo-123',
      endpoint: 'https://monitor.example.com/collect'
    });
    
    monitor.identifyUser({
      userId: 'user-789',
      displayName: 'usuario_demo'
    });
    
    monitor.logEvent('COMPRA_REALIZADA', {
      itemId: 'item-456',
      valor: 149.99,
      moneda: 'EUR'
    });
  </script>
</body>
</html>

// Integración con React
import React from 'react';
import { ReactErrorCatcher } from './monitor-sdk';

function App() {
  return (
    <ReactErrorCatcher 
      controller={monitor.workerController}
      fallback={<div>Error inesperado</div>}
    >
      <Router>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/producto" element={<Product />} />
        </Routes>
      </Router>
    </ReactErrorCatcher>
  );
}

function ProductCard({ item }) {
  const ref = React.useRef();
  
  React.useEffect(() => {
    if (ref.current) {
      monitor.logEvent('IMPRESION_PRODUCTO', {
        itemId: item.id,
        seccion: 'lista_productos'
      });
    }
  }, [item.id]);
  
  return (
    <div ref={ref} className="product-card">
      <img src={item.imagen} alt={item.nombre} />
      <h3>{item.nombre}</h3>
      <p>{item.precio}</p>
    </div>
  );
}

// Integración con Vue
import Vue from 'vue';
import { setupVueErrorHandling } from './monitor-sdk';

setupVueErrorHandling(Vue, monitor.workerController);

export default {
  name: 'UserProfile',
  methods: {
    trackProfile() {
      monitor.logEvent('PERFIL_VISTO', {
        userId: this.user.id,
        seccion: 'resumen'
      });
    }
  },
  mounted() {
    this.trackProfile();
  }
};

Configuración de compilación

Configuración para empaquetar el SDK como una biblioteca universal.

// webpack.config.js
module.exports = {
  entry: './src/kernel/main.js',
  output: {
    filename: 'monitor-sdk.js',
    path: path.resolve(__dirname, 'build'),
    library: 'monitor',
    libraryTarget: 'umd'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          }
        }
      }
    ]
  },
  optimization: {
    minimize: true
  }
};

El SDK incluye capacidades avanzadas como procesamiento en Web Workers, almacenamiento local con IndexedDB, adaptación dinámica a condiciones de red, anonimización automática de datos sensibles, monitoreo completo de errores, rendimiento e interacciones de usuario, y soporte para aplicaciones SPA con frameworks populares como React y Vue.

Etiquetas: JavaScript Web Workers Frontend Monitoring Performance Metrics Error Tracking

Publicado el 7-13 18:14