w-funnel/docs/UNLEASH_ANALYTICS_FIX.md
2025-10-22 22:42:01 +02:00

12 KiB
Raw Blame History

🐛 Проблема: Преждевременная отправка Unleash impression

Описание проблемы

Текущая реализация

// FunnelUnleashWrapper.tsx
// Сканирует ВСЮ воронку и загружает ВСЕ флаги сразу
const allFlags = useMemo(() => {
  funnel.screens.forEach(screen => {
    // Собирает флаги со ВСЕХ экранов
    screen.variants?.forEach(...)
    screen.navigation?.rules?.forEach(...)
  });
  return Array.from(flags);
}, [funnel.screens]);

// Получает варианты для ВСЕХ флагов
allFlags.map(flag => useVariant(flag));
// ↓
// Unleash SDK эмитит "impression" для ВСЕХ флагов
// ↓
// Отправка в GA для экранов, которые пользователь НЕ ВИДЕЛ

🐛 Проблемы:

  1. Преждевременная отправка

    Пользователь на экране 1/10
    → События отправлены для всех 10 экранов
    → Аналитика: "100% видели AB тест на экране 10"
    → Реальность: "Только 20% дошли до экрана 10"
    
  2. Повторная отправка при перезагрузке

    T+0: Пользователь открыл воронку → события отправлены
    T+60s: Пользователь перезагрузил страницу → события отправлены СНОВА
    → Дубликаты в аналитике
    
  3. Искажение метрик

    • Impression не отражает реальную видимость
    • Невозможно посчитать conversion rate "видел AB тест → совершил действие"
    • Завышенное количество impression событий

Решение 1: Per-Screen Impressions (Рекомендуется)

Идея:

Отправлять impression события только когда пользователь дошел до экрана

Реализация:

1. Создать новый хук useScreenUnleash:

// src/lib/funnel/unleash/useScreenUnleash.ts
export function useScreenUnleash(flags: string[]) {
  // Получаем варианты только для флагов ТЕКУЩЕГО экрана
  const variants = flags.map(flag => useVariant(flag));

  useEffect(() => {
    // Отправляем события только когда экран рендерится
    variants.forEach(({ flag, variant }) => {
      // Проверяем не отправляли ли уже (sessionStorage)
      const storageKey = `unleash_impression_${flag}_${variant}`;
      if (!sessionStorage.getItem(storageKey)) {
        // Отправка в GA
        window.gtag("event", "experiment_impression", {...});
        sessionStorage.setItem(storageKey, "true");
      }
    });
  }, [variants]);

  return activeVariants;
}

2. Использовать в FunnelRuntime:

// src/components/funnel/FunnelRuntime.tsx
export function FunnelRuntime({ funnel, initialScreenId }) {
  const currentScreen = ...;
  
  // Собираем флаги только для ТЕКУЩЕГО экрана
  const currentScreenFlags = useMemo(() => {
    const flags = new Set<string>();
    
    // Из вариантов текущего экрана
    currentScreen.variants?.forEach(variant => {
      variant.conditions.forEach(condition => {
        if (condition.conditionType === "unleash") {
          flags.add(condition.unleashFlag);
        }
      });
    });
    
    // Из правил навигации текущего экрана
    currentScreen.navigation?.rules?.forEach(rule => {
      rule.conditions.forEach(condition => {
        if (condition.conditionType === "unleash") {
          flags.add(condition.unleashFlag);
        }
      });
    });
    
    return Array.from(flags);
  }, [currentScreen]);
  
  // Загружаем и отправляем impression только для текущего экрана
  const screenVariants = useScreenUnleash(currentScreenFlags);
  
  // ... rest
}

Преимущества:

  1. Точная аналитика: impression = пользователь реально увидел экран
  2. Нет дубликатов: sessionStorage помнит отправленные события
  3. Последовательная загрузка: флаги загружаются по мере прохождения воронки

⚠️ Недостатки:

  1. Прогрузка флагов: небольшая задержка при переходе на новый экран (если флаг еще не загружен)
  2. Сложность: нужно отслеживать флаги для каждого экрана отдельно

Решение 2: Preload + Lazy Impression (Баланс)

Идея:

Загружать все флаги заранее (как сейчас), но отправлять impression только по мере просмотра экранов

Реализация:

1. Убрать автоматическую отправку из useUnleash:

// src/lib/funnel/unleash/useUnleash.ts
export function useUnleash({ flag }: UseUnleashProps) {
  // ... existing code
  
  // ❌ Убрать автоматическую отправку
  // useEffect(() => {
  //   unleashClient.on("impression", handleImpression);
  // }, [unleashClient]);
}

2. Создать хук для ручной отправки:

// src/lib/funnel/unleash/useSendImpression.ts
export function useSendImpression(
  flag: string,
  variant: string | undefined
) {
  useEffect(() => {
    if (!variant || variant === "disabled") return;
    if (typeof window === "undefined" || !window.gtag) return;

    // Проверка дубликатов
    const storageKey = `unleash_impression_${flag}_${variant}`;
    if (sessionStorage.getItem(storageKey)) return;

    // Отправка
    window.gtag("event", "experiment_impression", {
      app_name: "witlab-funnel",
      feature: flag,
      treatment: variant,
    });

    sessionStorage.setItem(storageKey, "true");
  }, [flag, variant]);
}

3. Отправлять вручную в FunnelRuntime:

// src/components/funnel/FunnelRuntime.tsx
export function FunnelRuntime({ funnel, initialScreenId }) {
  const { checkVariant } = useUnleashContext();
  const currentScreen = ...;
  
  // Флаги уже загружены через FunnelUnleashWrapper
  // Отправляем impression только для текущего экрана
  useEffect(() => {
    const currentFlags = extractFlagsFromScreen(currentScreen);
    
    currentFlags.forEach(flag => {
      const variant = checkVariant(flag, [], "includesAny");
      // Отправляем impression
      useSendImpression(flag, variant);
    });
  }, [currentScreen]);
}

Преимущества:

  1. Быстрый переход: флаги уже загружены заранее
  2. Точная аналитика: impression когда экран виден
  3. Нет дубликатов: sessionStorage

⚠️ Недостатки:

  1. Лишние запросы: загружаем флаги которые пользователь может не увидеть

Решение 3: Гибридный подход (Оптимальный)

Идея:

  • Загружаем флаги только для первых N экранов (например 3)
  • По мере прохождения догружаем следующие
  • Impression отправляем только при просмотре

Реализация:

// FunnelUnleashWrapper с preload окном
export function FunnelUnleashWrapper({ funnel, currentScreenIndex }) {
  const PRELOAD_WINDOW = 3; // Загружаем флаги для текущего + 2 следующих
  
  const preloadFlags = useMemo(() => {
    const startIndex = Math.max(0, currentScreenIndex);
    const endIndex = Math.min(funnel.screens.length, currentScreenIndex + PRELOAD_WINDOW);
    const screensToPreload = funnel.screens.slice(startIndex, endIndex);
    
    return extractFlagsFromScreens(screensToPreload);
  }, [currentScreenIndex, funnel.screens]);
  
  // Загружаем только флаги в окне preload
  const flagVariants = preloadFlags.map(flag => useVariant(flag));
  
  // Impression отправляется только в FunnelRuntime когда экран виден
}

Преимущества:

  1. Оптимальная производительность: загружаем только нужное
  2. Плавные переходы: следующие экраны уже готовы
  3. Точная аналитика: impression = видимость
  4. Экономия: не загружаем флаги для экранов 7-10 если пользователь на экране 2

📊 Сравнение решений

Критерий Текущее Решение 1 Решение 2 Решение 3
Точность аналитики Плохо Отлично Отлично Отлично
Скорость загрузки Быстро ⚠️ Медленно Быстро Быстро
Плавность переходов Плавно Лаги Плавно Плавно
Нет дубликатов Есть Нет Нет Нет
Экономия ресурсов Плохо Хорошо Плохо Отлично
Сложность реализации Простая ⚠️ Средняя ⚠️ Средняя Сложная

🎯 Рекомендация

Для witlab-funnel: Решение 2 (Preload + Lazy Impression)

Почему:

  1. Воронки короткие (обычно 5-10 экранов) - preload допустим
  2. Плавность переходов важна для UX
  3. Относительно просто реализовать
  4. Исправляет главную проблему - точность аналитики

Для длинных воронок (20+ экранов): Решение 3 (Гибридный)


📝 TODO для исправления

  • Создать useScreenUnleash.ts хук
  • Убрать автоотправку из useUnleash.ts (или сделать опциональной)
  • Добавить sessionStorage для предотвращения дубликатов
  • Интегрировать в FunnelRuntime.tsx
  • Обновить тесты
  • Обновить документацию

🔧 Быстрый фикс (минимальный)

Если нужно быстро исправить проблему с дубликатами при перезагрузке:

// src/lib/funnel/unleash/useUnleash.ts
useEffect(() => {
  const handleImpression = (impressionEvent) => {
    if (impressionEvent.enabled) {
      // Проверка дубликатов
      const storageKey = `unleash_${impressionEvent.featureName}_${impressionEvent.variant}`;
      if (sessionStorage.getItem(storageKey)) {
        return; // Уже отправляли
      }
      
      // Отправка
      window.gtag("event", "experiment_impression", {...});
      
      // Помечаем
      sessionStorage.setItem(storageKey, "true");
    }
  };
  
  unleashClient.on("impression", handleImpression);
}, [unleashClient]);

Это хотя бы предотвратит дубликаты при перезагрузке, но не решит проблему преждевременной отправки.