За два года мы существенно улучшили разметку в Яндекс Лавке. В этой статье расскажем, что не устраивало аналитиков в старой версии и как с этими ограничениями боролись разработчики.
Что такое разметка
Разметка — это набор событий, которые мы собираем по действиям пользователя в приложении. События — сами действия, например клики или просмотры. И в каждое событие пробрасываются параметры с их описанием, например стоимостью доставки.

Для чего нужна разметка
Если коротко — для любой продуктовой аналитики:
- Строить дашборды
DWH собирает пользовательские сессии по разметке, и все дашборды далее мы строим при помощи данных, полученных из DWH, или сырых событий из AppMetrica.
- Проводить эксперименты
В рамках экспериментов хочется замечать большие интегральные изменения, которые влияют на бизнес, но зачастую это невозможно. Мы можем сегментировать пользователей при помощи разметки или смотреть на более чувствительные метрики. Например, конверсии, как правило, чувствительнее, чем денежные метрики.
- Проводить исследования
Как и с экспериментами, разметка помогает ускорять исследования и качественнее готовить данные. Например, можно оценить влияние фичи перед проведением эксперимента.
Что было не так с разметкой раньше
Несколько неудобных процессов замедляли работу и мешали качественно строить аналитику.
Документацию вели вручную на Вики. Там сложно вносить изменения и искать информацию по таблицам. Кроме того, не было гарантий, что на странице размещена актуальная документация.

При добавлении нового экрана нужно было менять параметры origin_screen вручную.

Не было системного подхода к заведению событий. Чтобы внести правки в разметку, создавали тикеты с перечислением всех изменений. Обсуждение по таким процессам удобнее вести в разных тредах, чтобы правки не терялись и согласование было прозрачным. В комментариях в Трекере нельзя сделать разные ветки, так что обсуждать изменения при большом количестве участников было неудобно.

Многие события отрабатывали некорректно. Чем ниже качество данных, тем сложнее их обрабатывать, дебажить и поддерживать.
Как мы перевели Лавку на новую разметку
Для этого мы разработали процесс создания разметки, придумали правила написания разметки для аналитиков и перешли на автоматическое формирование документации.
Теперь, чтобы внести любое изменение, нужно создать в Трекере задачу и заполнить обязательные поля. Это повысило прозрачность: сразу указан проект, в рамках которого происходят изменения, аналитик, который будет менять документацию, разработчик, который будет обновлять код, и менеджер, отвечающий за проект.

Для задачи автоматически формируется документация — достаточно завести пулл-реквест в Аркадии. Добавился удобный поиск, а обсуждение правок происходит в тредах в рамках пулл-реквеста.

В новом процессе ввели обязательное ревью изменений с помощью сторонних разработчиков и аналитиков.
Теперь создание разметки выглядит так:

Как это выглядело со стороны разработки
В плане разработки тоже были ограничения, которые усложняли разметку и, как следствие, анатилитику.
Была сложная система определения экрана и источника. Раньше мы собирали набор экранов (origin_screen) и источников (source) в общее хранилище. Впоследствии, при отправке события, брали последний экран и источник из общего массива. Так как мы отправляли события синхронно, то в какой-то момент могли отправить их быстрее или, наоборот, медленнее, чем обновился экран. Тогда данные были неактуальны.
// Методы для установки и получения источника
pushScreenLog(screen: GrocerySourceName) {
this.screenLogs.push(screen)
}
getScreenLogs(): GrocerySourceName[] {
return this.screenLogs
}
getCurrentScreenLog(): GrocerySourceName {
return this.screenLogs[this.screenLogs.length - 1] ?? fallbackScreenLog
}
getPrevScreenLog(): GrocerySourceName {
return this.screenLogs[this.screenLogs.length - 2] ?? fallbackScreenLog
}
removeLastScreenLog(): GrocerySourceName | undefined {
return this.screenLogs.pop()
}
Как определяли источник и экран
Специальный хук useMetrikaSource записывал экраны и источники. Допустим, мы вносили значение item_select. Затем было какое-то событие, например lavka.upsale_loaded. Мы отправляли в событие lavka.upsale_loaded параметр origin_screen и source, которые получали из общего хранилища.
// Хук для работы с источником и экраном
export function useMetrikaSource(name: GrocerySourceName,
opts?: { disabled?: boolean; withCleanup?: boolean }) {
const setScreenSource = useSetScreenSource()
const { disabled = false, withCleanup = false } = opts ?? {}
useLayoutEffect(() => {
if (!isBrowser || disabled) return
metrika.pushScreenLog(name)
setScreenSource(name)
return () => {
if (withCleanup) {
metrika.removeLastScreenLog()
}
}
}, [name, withCleanup, disabled])
}
// Установка значения источника или экрана
useMetrikaSource('category_menu’)
useMetrikaSource('item_selected')
// Отправка значения source и origin_screen в событии
upsaleLoaded({placeInfo, type, items}: {
items: IMetrikaUpsaleProduct[]; type?: string; placeInfo: PlaceInfo}) {
metrika.reachGoal('lavka.upsale_loaded', {
...placeInfo,
origin_screen: metrika.getCurrentScreenLog(),
source: metrika.getPrevScreenLog(),
total_available: items.length,
upsale_version: type ?? ’’,
items,
})
}
Хранили данные событий для отправки на следующей странице. Чтобы узнать что-то с предыдущей страницы, нужно было сначала сохранить информацию, а затем отправить её на следующую страницу. В процессе данные могли потеряться или неправильно записаться, систему было сложно поддерживать и валидировать.
pushEventData<T extends keyof IMetrikaEvents>(event: T, data: Partial<IMetrikaEvents[T]>) {
const log = this.logsMap.get(event)
const newData = merge(log, data)
// при операции merge значения undefined не затирают существующие значения
Object.entries(data).forEach(([name, value]) => {
if (value === undefined) {
delete newData[name]
}
})
this.logsMap.set(event, newData)
}
getEventData<T extends keyof IMetrikaEvents>(event: T): Partial<IMetrikaEvents[T]> {
return (this.logsMap.get(event) as Partial<IMetrikaEvents[T]>) ?? {}
}
clearEventData(event: keyof IMetrikaEvents) {
this.logsMap.delete(event)
}
Установка данных события
Отправляли события синхронно. Поэтому мы могли терять не только неправильно записанные экраны или источники, но и обязательные поля. Например, отправить событие открытия категории без category_id, потому что к тому моменту этот параметр еще не успел отразиться в нужном месте.
Отправляли события просмотра каждые 10 секунд. При таком подходе мы теряли часть событий, если за это время пользователь свернул или закрыл приложение.
Передавали лишние данные о событиях. Поскольку мы использовали аналитику тех времён, когда Лавка существовала только в приложении Еды, было много полей, которые нашему сервису не нужны.
Что мы с этим сделали
Все проблемы решили поэтапно.
Поменяли процесс установки экрана и источника события. Теперь определяем источник и экран непосредственно там, где открывается этот экран. Мы указываем это текстом или специальным параметром и передаём в компонент, в котором отправляется аналитика. Так мы гарантируем, что при отправке события передаётся актуальный экран.
// Установка значения источника или экрана
const analyticsParams: AnalyticsUpsaleParams = {
originScreen: 'main_page’,
marketSource: 'hour_slot_delivery',
productItemOriginScreen: 'main_page',
productItemSource: 'bottom_uplift',
}
// Отправка значения источника или экрана
const sendViewed = useCallbackRef((entities: UpsaleViewedEntity[]) => {
upliftAnalytics.upliftViewed({
entities,
originScreen: analyticsParams.originScreen,
marketSource: analyticsParams.marketSource,
depotType,
})
})
const onVisibilityChange = useViewed(sendViewed)
Новый процесс для определения экрана и источника
Данные событий стали либо передавать напрямую в события, либо собирать в контексты и передавать в компоненты. Когда мы обрабатываем данные, то сразу собираем контекст, в который помещаем нужную информацию, чтобы отправить всё это аналитику: origin_screen, source, ID и пр. Если при отправке события произошёл просмотр этого элемента, мы достаём из контекста нужную информацию и отправляем в событие.
// Установка данных события в контекст
const productListItems = useMemo<ProductListItemEntry<CatalogProduct>[]>(() => {
return (isLoading
? [...(products ?? []), ...typedEmptyArray(limit)]
: products ?? []).map((item, index) => item
? {
data: item.value,
size: size ?? CARD_SIZE[`${item.layout.width}x${item.layout.height}`
as keyof typeof CARD_SIZE],
context: {
pageNumber: item.value.pageNumber,
traceId: item.value.traceId,
originScreen: analyticsParams.productItemOriginScreen,
source: analyticsParams.productItemSource,
index,
},
}
: {size: size ?? 's’},
)
}, [analyticsParams.productItemOriginScreen, analyticsParams.productItemSource,
isLoading, products, size, limit])
Теперь данные события устанавливаются в контекст
Написали готовые утилиты для отправки событий. Для отправки перехода на страницу используем хук useOpened, для просмотра элемента страницы — хук useViewed. Все события, которые осуществляются по клику, оборачиваем в useCallbackRef, чтобы отправлять нужные данные.
Реализовали асинхронную отправку событий. Теперь события отправляются, только когда мы получили все обязательные поля. Это позволяет ничего не терять.
Стали отправлять события просмотра либо после ухода с поверхности, либо при накоплении 64 КБ — это около 50 событий.
Оставили только нужные поля. Избавились от багажа Еды и стали отправлять только те поля, которые используются при расчётах аналитических данных.


Чтобы всё работало так и дальше, мы ввели контроль качества. В него входит:
- Ревью спек аналитиком и разработчиком, желательно ответственным за фичу.
- Тестирование и автотесты на аналитику. Сейчас у нас около 29 тест-кейсов для аналитики.
- Дашборды, которые отслеживают количество событий в динамике. Если какое-то событие начинает происходить существенно чаще или реже ожидаемого, в чат разработчикам и дежурным аналитикам приходит автоматический алерт.

Правила разметки
Мы вывели правила, на которые опирались при переходе на новую разметку.
-
Размечаем всё — чтобы знать, как пользователь ведёт себя на каждом этапе взаимодействия с приложением.
-
Разделяем клики и просмотры. Например, не любое открытие категорий товаров — полезное действие. Для бизнеса полезным действием считается, когда пользователь взаимодействовал с товаром: посмотрел, добавил в избранное или в корзину. Для отслеживания таких действий мы выделяем отдельную категорию — события с просмотрами. Событие классифицируется так, если пользователь взаимодействовал с карточкой товара заданное время и видел большую часть сниппета.

3. Различаем поверхности и экраны. Экран — это, например, страница корзины, поиска или чекаута. Поверхность — то, что располагается на экране: карусель товаров, лента рекомендаций и пр.

4. Следим, чтобы не было дублей — они искажают информацию, в частности приводят к занижению конверсий.
Например, в корзине есть переключатель «Отдать пакеты и получить Х баллов». Если при нажатии на переключатель присылать отдельное событие, то будут создаваться дубли и ненужный трафик. Поэтому теперь присылается только конечное состояние экрана и информация о том, были ли нажатия на переключатель и сколько раз.

Что дальше
Начали покрывать сайт новой разметкой — пока там ещё есть компоненты без аналитики. Также будем увеличивать покрытие событий автотестами.
Постепенно проводим рефакторинг событий и их названий. Когда мы переходили на новую аналитику, в основном использовали названия событий и параметры, похожие на старые. Но иногда они плохо отражают суть. Хочется, чтобы из названия и параметров сразу было ясно, что это за событие.
Надеемся, наша история поможет и вам. А мы пошли дальше совершенствовать свою новую разметку.