325 lines
12 KiB
Markdown
325 lines
12 KiB
Markdown
# 🐛 Проблема: Преждевременная отправка Unleash impression
|
||
|
||
## Описание проблемы
|
||
|
||
### ❌ Текущая реализация
|
||
|
||
```typescript
|
||
// 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`:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// src/lib/funnel/unleash/useUnleash.ts
|
||
export function useUnleash({ flag }: UseUnleashProps) {
|
||
// ... existing code
|
||
|
||
// ❌ Убрать автоматическую отправку
|
||
// useEffect(() => {
|
||
// unleashClient.on("impression", handleImpression);
|
||
// }, [unleashClient]);
|
||
}
|
||
```
|
||
|
||
#### 2. Создать хук для ручной отправки:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// 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 отправляем только при просмотре
|
||
|
||
### Реализация:
|
||
|
||
```typescript
|
||
// 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`
|
||
- [ ] Обновить тесты
|
||
- [ ] Обновить документацию
|
||
|
||
---
|
||
|
||
## 🔧 Быстрый фикс (минимальный)
|
||
|
||
Если нужно быстро исправить проблему с дубликатами при перезагрузке:
|
||
|
||
```typescript
|
||
// 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]);
|
||
```
|
||
|
||
Это хотя бы предотвратит дубликаты при перезагрузке, но не решит проблему преждевременной отправки.
|