12 KiB
12 KiB
🐛 Проблема: Преждевременная отправка 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/10 → События отправлены для всех 10 экранов → Аналитика: "100% видели AB тест на экране 10" → Реальность: "Только 20% дошли до экрана 10" -
Повторная отправка при перезагрузке
T+0: Пользователь открыл воронку → события отправлены T+60s: Пользователь перезагрузил страницу → события отправлены СНОВА → Дубликаты в аналитике -
Искажение метрик
- 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
}
✅ Преимущества:
- Точная аналитика: impression = пользователь реально увидел экран
- Нет дубликатов: sessionStorage помнит отправленные события
- Последовательная загрузка: флаги загружаются по мере прохождения воронки
⚠️ Недостатки:
- Прогрузка флагов: небольшая задержка при переходе на новый экран (если флаг еще не загружен)
- Сложность: нужно отслеживать флаги для каждого экрана отдельно
✅ Решение 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]);
}
✅ Преимущества:
- Быстрый переход: флаги уже загружены заранее
- Точная аналитика: impression когда экран виден
- Нет дубликатов: sessionStorage
⚠️ Недостатки:
- Лишние запросы: загружаем флаги которые пользователь может не увидеть
✅ Решение 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 когда экран виден
}
✅ Преимущества:
- Оптимальная производительность: загружаем только нужное
- Плавные переходы: следующие экраны уже готовы
- Точная аналитика: impression = видимость
- Экономия: не загружаем флаги для экранов 7-10 если пользователь на экране 2
📊 Сравнение решений
| Критерий | Текущее | Решение 1 | Решение 2 | Решение 3 |
|---|---|---|---|---|
| Точность аналитики | ❌ Плохо | ✅ Отлично | ✅ Отлично | ✅ Отлично |
| Скорость загрузки | ✅ Быстро | ⚠️ Медленно | ✅ Быстро | ✅ Быстро |
| Плавность переходов | ✅ Плавно | ❌ Лаги | ✅ Плавно | ✅ Плавно |
| Нет дубликатов | ❌ Есть | ✅ Нет | ✅ Нет | ✅ Нет |
| Экономия ресурсов | ❌ Плохо | ✅ Хорошо | ❌ Плохо | ✅ Отлично |
| Сложность реализации | ✅ Простая | ⚠️ Средняя | ⚠️ Средняя | ❌ Сложная |
🎯 Рекомендация
Для witlab-funnel: Решение 2 (Preload + Lazy Impression)
Почему:
- Воронки короткие (обычно 5-10 экранов) - preload допустим
- Плавность переходов важна для UX
- Относительно просто реализовать
- Исправляет главную проблему - точность аналитики
Для длинных воронок (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]);
Это хотя бы предотвратит дубликаты при перезагрузке, но не решит проблему преждевременной отправки.