Skip to content

Analítica y telemetría para juegos web

La analítica te ayuda a entender qué hacen los jugadores, dónde encuentran dificultades y qué mantiene su interés. Este tutorial muestra qué registrar y cómo hacerlo.

1) Qué registrar

Métricas de interacción:

  • Inicios y duración de las sesiones
  • Niveles iniciados y completados
  • Funciones utilizadas
  • Retención (visitas recurrentes)

Métricas de rendimiento:

  • Tiempo de carga
  • Tasa de fotogramas
  • Uso de memoria
  • Errores y cierres inesperados

Métricas de conversión:

  • Finalización del tutorial
  • Primera compra
  • Contenido compartido en redes sociales

2) Registro sencillo de eventos

js
class Analytics {
  constructor(endpoint) {
    this.endpoint = endpoint
    this.sessionId = crypto.randomUUID()
    this.queue = []
    this.flushInterval = 30000 // 30 seconds
    
    setInterval(() => this.flush(), this.flushInterval)
    // Flush when the page is being hidden, not on beforeunload.
    // beforeunload/unload are unreliable (often don't fire on mobile,
    // and block the back/forward cache). visibilitychange + pagehide
    // are the recommended events for end-of-session sends.
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') this.flush()
    })
    window.addEventListener('pagehide', () => this.flush())
  }
  
  track(event, data = {}) {
    this.queue.push({
      event,
      data,
      sessionId: this.sessionId,
      timestamp: Date.now(),
      url: location.href,
    })
    
    // Flush immediately for important events
    if (event === 'error' || event === 'purchase') {
      this.flush()
    }
  }
  
  async flush() {
    if (this.queue.length === 0) return
    
    const events = [...this.queue]
    this.queue = []
    
    try {
      await fetch(this.endpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ events }),
        keepalive: true, // Important for beforeunload
      })
    } catch {
      // Put events back in queue
      this.queue.unshift(...events)
    }
  }
}

const analytics = new Analytics('/api/analytics')

3) Registro de sesiones

js
// Track session start
analytics.track('session_start', {
  referrer: document.referrer,
  screen: `${screen.width}x${screen.height}`,
  devicePixelRatio: window.devicePixelRatio,
  userAgent: navigator.userAgent,
})

// Track session end
let sessionStart = Date.now()

// Send session_end when the page is hidden, not on beforeunload.
// beforeunload is unreliable on mobile and breaks the bfcache.
function recordSessionEnd() {
  analytics.track('session_end', {
    duration: Date.now() - sessionStart,
  })
}
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') recordSessionEnd()
})
window.addEventListener('pagehide', recordSessionEnd)

// Track visibility changes
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    analytics.track('tab_hidden')
  } else {
    analytics.track('tab_visible')
  }
})

4) Eventos específicos del juego

js
// Level tracking
function onLevelStart(levelId) {
  analytics.track('level_start', { levelId })
}

function onLevelComplete(levelId, score, time) {
  analytics.track('level_complete', {
    levelId,
    score,
    timeSeconds: time,
  })
}

function onLevelFail(levelId, reason) {
  analytics.track('level_fail', {
    levelId,
    reason, // 'death', 'timeout', 'quit'
  })
}

// Achievement tracking
function onAchievementUnlocked(achievementId) {
  analytics.track('achievement', { achievementId })
}

// Tutorial tracking
function onTutorialStep(step, skipped = false) {
  analytics.track('tutorial', { step, skipped })
}

5) Supervisión del rendimiento

js
class PerformanceMonitor {
  constructor(analytics) {
    this.analytics = analytics
    this.frameTimes = []
    this.lastFrame = performance.now()
  }
  
  recordFrame() {
    const now = performance.now()
    this.frameTimes.push(now - this.lastFrame)
    this.lastFrame = now
    
    // Keep last 60 frames
    if (this.frameTimes.length > 60) {
      this.frameTimes.shift()
    }
  }
  
  getAverageFPS() {
    if (this.frameTimes.length === 0) return 0
    const avgFrameTime = this.frameTimes.reduce((a, b) => a + b) / this.frameTimes.length
    return 1000 / avgFrameTime
  }
  
  reportPerformance() {
    const fps = this.getAverageFPS()
    const memory = performance.memory?.usedJSHeapSize
    
    this.analytics.track('performance', {
      avgFPS: Math.round(fps),
      memoryMB: memory ? Math.round(memory / 1024 / 1024) : null,
    })
  }
}

// Report every minute
const perfMonitor = new PerformanceMonitor(analytics)
setInterval(() => perfMonitor.reportPerformance(), 60000)

Atribuir los tirones con Long Animation Frames

El contador de FPS anterior te indica cuándo se pierden fotogramas, pero no por qué. La API Long Animation Frames (LoAF), incorporada en Chrome y Edge 123, cubre esa carencia. Detecta cualquier fotograma que tarde más de 50 ms y desglosa qué scripts provocaron el retraso, para que puedas atribuir los tirones a código específico en lugar de hacer conjeturas.

js
if (PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      analytics.track('long_frame', {
        durationMs: Math.round(entry.duration),
        blockingMs: Math.round(entry.blockingDuration),
        // scripts[] names the source URLs that ate the frame
        scripts: entry.scripts?.map((s) => s.sourceURL),
      })
    }
  })
  observer.observe({ type: 'long-animation-frame', buffered: true })
}

Por ahora, LoAF solo está disponible en Chromium (no en Firefox ni Safari), así que comprueba si la función está disponible antes de usarla y conserva el contador de FPS como referencia compatible con todos los navegadores.

6) Registro de errores

js
window.addEventListener('error', (event) => {
  analytics.track('error', {
    message: event.message,
    filename: event.filename,
    lineno: event.lineno,
    colno: event.colno,
    stack: event.error?.stack,
  })
})

window.addEventListener('unhandledrejection', (event) => {
  analytics.track('error', {
    message: event.reason?.message || String(event.reason),
    type: 'unhandledrejection',
    stack: event.reason?.stack,
  })
})

// Custom error tracking
function trackGameError(context, error) {
  analytics.track('game_error', {
    context,
    message: error.message,
    stack: error.stack,
  })
}

7) Registro del tiempo de carga

js
// Track initial load
window.addEventListener('load', () => {
  const timing = performance.timing
  const loadTime = timing.loadEventEnd - timing.navigationStart
  const domReady = timing.domContentLoadedEventEnd - timing.navigationStart
  
  analytics.track('page_load', {
    totalMs: loadTime,
    domReadyMs: domReady,
  })
})

// Track game-specific load phases
async function loadGame() {
  const start = performance.now()
  
  await loadCriticalAssets()
  const criticalTime = performance.now() - start
  
  analytics.track('load_critical', { ms: Math.round(criticalTime) })
  
  await loadGameAssets()
  const totalTime = performance.now() - start
  
  analytics.track('load_complete', { ms: Math.round(totalTime) })
}

8) Registro de embudos

Registra el progreso de los jugadores a través de los flujos clave:

js
class FunnelTracker {
  constructor(analytics, funnelName) {
    this.analytics = analytics
    this.funnelName = funnelName
    this.startTime = Date.now()
  }
  
  step(stepName) {
    this.analytics.track('funnel_step', {
      funnel: this.funnelName,
      step: stepName,
      elapsedMs: Date.now() - this.startTime,
    })
  }
  
  complete() {
    this.analytics.track('funnel_complete', {
      funnel: this.funnelName,
      totalMs: Date.now() - this.startTime,
    })
  }
  
  abandon(reason) {
    this.analytics.track('funnel_abandon', {
      funnel: this.funnelName,
      reason,
      elapsedMs: Date.now() - this.startTime,
    })
  }
}

// Usage
const onboarding = new FunnelTracker(analytics, 'onboarding')
onboarding.step('welcome_shown')
// ... player clicks continue
onboarding.step('name_entered')
// ... player completes tutorial
onboarding.complete()

9) Compatibilidad con pruebas A/B

js
class ABTest {
  constructor(testName, variants) {
    this.testName = testName
    this.variants = variants
    
    // Get or assign variant
    const stored = localStorage.getItem(`ab_${testName}`)
    if (stored && variants.includes(stored)) {
      this.variant = stored
    } else {
      this.variant = variants[Math.floor(Math.random() * variants.length)]
      localStorage.setItem(`ab_${testName}`, this.variant)
    }
    
    // Track assignment
    analytics.track('ab_assignment', {
      test: testName,
      variant: this.variant,
    })
  }
  
  getVariant() {
    return this.variant
  }
  
  trackConversion(metric) {
    analytics.track('ab_conversion', {
      test: this.testName,
      variant: this.variant,
      metric,
    })
  }
}

// Usage
const difficultyTest = new ABTest('difficulty', ['easy', 'normal', 'hard'])
game.difficulty = difficultyTest.getVariant()

// When player completes level
difficultyTest.trackConversion('level_complete')

10) Consideraciones sobre la privacidad

js
class PrivacyAwareAnalytics extends Analytics {
  constructor(endpoint) {
    super(endpoint)
    this.enabled = this.checkConsent()
  }
  
  checkConsent() {
    return localStorage.getItem('analytics_consent') === 'true'
  }
  
  setConsent(enabled) {
    localStorage.setItem('analytics_consent', enabled ? 'true' : 'false')
    this.enabled = enabled
    
    if (enabled) {
      this.track('consent_granted')
    }
  }
  
  track(event, data = {}) {
    if (!this.enabled) return
    
    // Strip PII
    const sanitized = { ...data }
    delete sanitized.email
    delete sanitized.name
    delete sanitized.ip
    
    super.track(event, sanitized)
  }
}

// Show consent dialog
function showConsentDialog() {
  const dialog = document.createElement('div')
  dialog.innerHTML = `
    <p>Utilizamos analítica para mejorar el juego. ¿Estás de acuerdo?</p>
    <button id="accept">Aceptar</button>
    <button id="decline">Rechazar</button>
  `
  document.body.appendChild(dialog)
  
  dialog.querySelector('#accept').onclick = () => {
    analytics.setConsent(true)
    dialog.remove()
  }
  
  dialog.querySelector('#decline').onclick = () => {
    analytics.setConsent(false)
    dialog.remove()
  }
}

Alternativas de terceros

Si no quieres crear tu propia solución:

  • Plausible — Sencillo y centrado en la privacidad
  • Amplitude — Analítica de producto y embudos
  • Mixpanel — Registro de eventos y recorridos de usuario
  • Sentry — Especializado en el registro de errores
js
// Example: Plausible
const script = document.createElement('script')
script.defer = true
script.dataset.domain = 'yourgame.com'
script.src = 'https://plausible.io/js/plausible.js'
document.head.appendChild(script)

// Track custom events
window.plausible('level_complete', { props: { level: '1' } })

Relacionado

Recursos externos

  • Plausible Analytics — analítica ligera y respetuosa con la privacidad
  • PostHog — analítica de producto de código abierto con registro de eventos
  • Sentry — supervisión de errores e informes de cierres inesperados para aplicaciones web