Продуктовая аналитика в одностраничном веб-приложении (SPA). Часть 1

В этой статье я расскажу о базовой аналитике в связке GTM – Amplitude на SPA (Angular), в т.ч WebView на Android и iOS.

Что такое SPA коротко и доступно написано тут:

Про аналитику в Amplitude рекомендую максимально полезные статьи от Тимура Тукаева

Вероятно, какие-то вещи я повторю снова – т.к сложно избежать желания давать советы.

Важно! Предполагается, что читатель обладает базовыми знаниями и уже пробовал работать с Amplitude и Google Tag Manager

Проблемы аналитики одностраничных веб-приложений

Некоторые продуктовые менеджеры или аналитики привыкли к тому, что по веб-сайтам базовая аналитика собирается из коробки. В первую очередь речь идет про переходы между страницами. Всё, что вы увидите в Google Analytics для страниц на Angular или React – это будет название вашего сайта (как правило это и будет заголовком первой страницы).

Увы, из коробки ничего полезного собираться не будет. Но если вы когда-либо работали с нативными мобильными приложениями, то сложностей с понимаем основных принципов не будет. Хотите получить данные – их нужно разметить (с командой разработки).

Т.к нам все равно все размечать руками, то мой выбор в пользу Amplitude. В следующий раз попробую описать, что можно сделать и с GA4.

Зачем GTM и почему бы не использовать его вместо разработки?

Google Tag Manager полезный инструмент, даже не смотря на ограничения в работе на SPA. Сложность в том, что если проект живой и в разработке, то гарантированно разметить триггеры в GTM (для SPA) не получится - не буду лукавить, есть теги, которые за 6 месяцев жизни проекта действительно не менялись.

За полгода из работающих событий в Amplitude созданных с помощью обычных механизмов GTM (когда вы размечаете триггеры самостоятельно, например, по селекторам - без разработки) осталось не более 30%

Да, селекторы в Angular (и любом другом SPA) тоже часто меняются. Даже название поля ввода - mat-input-0 завтра может стать mat-input-1, если в проекте появились новые поля ввода. SPA вообще не гарантирует нейминг селекторов - хотя тут скорее всего со мной кто-то может поспорить.

Тогда зачем GTM?

- С его помощью я запускаю разные конфигурации Amplitude на разных контурах для теста и для боя (нет смысла заставлять разработчиков решать такую задачу)

- Добавляю свойства событий и пользователей (например, браузер пользователя или размеры разрешения браузера). Много всего, у всех задачи разные

- Проверяю локальные проблемы добавляя временно новые события с помощью селекторов или других идентификаторов

и т.д

Т.е GTM передает события в Amplitude и это удобно, потому что данные можно модифицировать.

Подробнее про GTM почитать тут:

Что под капотом?

Конфигурация Amplitude в GTM

Продуктовая аналитика в одностраничном веб-приложении (SPA). Часть 1

Код в поле HTML для тега конфигурации взять из кабинета Amplitude - JavaScript SDK.

Из необычного в проекте сделан кастомный триггер, который по доменному имени запускает разные конфигурации проектов в Амплитуде.

Саму проверку делаю в переменной

Продуктовая аналитика в одностраничном веб-приложении (SPA). Часть 1

Переменная всегда должна что-то возвращать. Код не самый красивый - т.к часто преобразовывался, но тут скорее про смысл (домен конечно заменен).

С этой переменной работает Триггер на инициализацию с условием активации тега на значение переменной выше (триггера 3 - на 3 разных контура).

Дальше самое важное: фронт шлет пуш-события в data-слой по интересующим вас событиям. Для разработки, это будет что-то вроде:

"window.dataLayer.push({'event':'<название ивента из таблички>', 'property1':'<название свойства события из таблички>', 'property2':'<значения свойства события из таблички>'})

Свойства property1 и property2 из примера вы можете назвать как угодно, но именно с этими именами вам нужно будет создать переменные в GTM чтобы передать их в события. Их может быть столько, сколько вам нужно.

Если разработка все сделала правильно, то в консоли GTM появятся события с неймингом из поля event, свойства этих событий можно посмотреть переключившись на вкладку Data Layer

Продуктовая аналитика в одностраничном веб-приложении (SPA). Часть 1

Подробнее про дата слой тут:

Подключили GTM, настроили конфигурацию Amplitude, шлем пуш события в дата слой - что дальше?

Дальше в GTM создаем Тег с типом Пользовательский HTML

Продуктовая аналитика в одностраничном веб-приложении (SPA). Часть 1

В теге будет примерно следующий код для каждого тэга для события:

<script> var eventProperties = {}; eventProperties.data = {{Data}}; eventProperties.device = {{Device Type}}; eventProperties.platform = {{Platform}}; eventProperties.screen = {{Screen Size}}; eventProperties.timeZone = {{TimeZone}}; eventProperties.touch = {{Touch screen}}; eventProperties.userAgent = {{User Agent}}; eventProperties.parametr1 = {{Parametr1}}; eventProperties.parametr2 = {{Parametr1}}; amplitude.getInstance().setUserProperties({'data': eventProperties.data}); amplitude.getInstance().logEvent('MOE_SOBITIE_DLYA_AMPLITUDE', eventProperties); </script>

В фигурных скобках ссылки на созданные переменные в GTM.

Если вам не нужна кастомизация под разные события, то вместо имени события 'MOE_SOBITIE_DLYA_AMPLITUDE' можно написать {{Event}} (так же заведите его, как переменную) - тогда нейминг ваших событий от разработки сразу будет прокидываться в Амплитуду.

Коллеги массово пишут не использовать много свойств событий, но я могу сказать, что именно по свойствам иногда получается находить ошибки - когда у тебя прилетает экран для авторизованного пользователя, а все свойства говорят об обратном.

Ну и наш Тег (теги) с пользовательским HTML должны запускаться по Триггеру "Специальные события"

Продуктовая аналитика в одностраничном веб-приложении (SPA). Часть 1

Где в поле "Название события" указано значение поле Event от разработки, т.е то значение, что отображает консоль GTM.

Если у вас один Тег с именем {{Event}}, которое передается в Amplitude для всех событий, как я писал выше, то у него будет множество Триггеров под каждое событие. Не забывайте добавлять новые Триггеры с появлением новых ивентов от разработки.

Строчка с amplitude.getInstance().setUserProperties({'data': eventProperties.data}); скорее всего в рамках этой статьи вам не нужна - она присваивает свойства пользователю. Я думаю расскажу об этих особенностях в другой раз.

Полезные переменные

К сожалению, авторство указать сейчас для меня крайне сложно, но думаю кому важно загуглит. Точно помню, что много взял, например, тут:

Название переменных должно быть доступным и легко читаемым - именно к нему вы будете обращаться в Тегах в фигурных скобках.

Тип устройства по ширине экрана

function () { var width = window.innerWidth, screenType; if (width <= 768) { screenType = "mobile"; } else if (width > 768 && width <= 992) { screenType = "tablet"; } else { screenType = "desktop"; } return screenType; }

Платформа

function () {return navigator.platform;}

Размер экрана

function() { var width = Math.max(document.documentElement.clientWidth, window.innerWidth || 0); var height = Math.max(document.documentElement.clientHeight, window.innerHeight || 0); var screenSize = width + 'x' + height; return screenSize; }

Временная зона

function() { var timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; return timeZone; }

Проверка на Touch Screen

function isTouch() { try{ document.createEvent("TouchEvent"); return true; } catch(e){ return false; } }

Юзер Агент

function () {return navigator.userAgent;}

Например, так выглядит парсинг ЮТМ меток (чтобы так же записать)

Продуктовая аналитика в одностраничном веб-приложении (SPA). Часть 1

Из полезного - метку не перехватить, если в SPA на запуске сразу срабатывает редирект. Надо либо передавать в рекламу конечную страницу, либо с разработкой делать проброс меток.

Вот так в переменную забрать свойства события, для каждого свойства - своя переменная

Продуктовая аналитика в одностраничном веб-приложении (SPA). Часть 1
4
2 комментария
","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

В фигурных скобках ссылки на созданные переменные в GTM.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Если вам не нужна кастомизация под разные события, то вместо имени события 'MOE_SOBITIE_DLYA_AMPLITUDE' можно написать {{Event}} (так же заведите его, как переменную) - тогда нейминг ваших событий от разработки сразу будет прокидываться в Амплитуду.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Коллеги массово пишут не использовать много свойств событий, но я могу сказать, что именно по свойствам иногда получается находить ошибки - когда у тебя прилетает экран для авторизованного пользователя, а все свойства говорят об обратном.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Ну и наш Тег (теги) с пользовательским HTML должны запускаться по Триггеру \"Специальные события\"

"}},{"type":"media","cover":false,"hidden":false,"anchor":"","data":{"items":[{"title":"","image":{"type":"image","data":{"uuid":"a7ecbb34-13b0-5536-a4da-58f0bfc46b86","width":921,"height":305,"size":16819,"type":"png","color":"e9eaea","hash":"","external_service":[]}}}]}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Где в поле \"Название события\" указано значение поле Event от разработки, т.е то значение, что отображает консоль GTM.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Если у вас один Тег с именем {{Event}}, которое передается в Amplitude для всех событий, как я писал выше, то у него будет множество Триггеров под каждое событие. Не забывайте добавлять новые Триггеры с появлением новых ивентов от разработки.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Строчка с amplitude.getInstance().setUserProperties({'data': eventProperties.data}); скорее всего в рамках этой статьи вам не нужна - она присваивает свойства пользователю. Я думаю расскажу об этих особенностях в другой раз.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Полезные переменные

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

К сожалению, авторство указать сейчас для меня крайне сложно, но думаю кому важно загуглит. Точно помню, что много взял, например, тут:

"}},{"type":"link","cover":false,"hidden":false,"anchor":"","data":{"link":{"type":"link","data":{"url":"https://api.vc.ru/v2.8/redirect?to=https%3A%2F%2Fosipenkov.ru%2Fanalytics%2F%3Fref%3Dvc.ru&postId=268754","title":"Статьи по веб-аналитике","description":"Статьи по веб-аналитике, работа с инструментами Google Analytics, Google Tag Manager, Яндекс.Метрика, Google Data Studio, Microsoft Power BI и Excel","image":{"type":"image","data":{"uuid":"7a16a6d5-01eb-5151-83ba-4dab1bf93cd1","width":300,"height":300,"size":6002,"type":"png","color":"29c1e0","hash":"","external_service":[]}},"v":1,"hostname":"osipenkov.ru"}}}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Название переменных должно быть доступным и легко читаемым - именно к нему вы будете обращаться в Тегах в фигурных скобках.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Тип устройства по ширине экрана

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"function () {\n var width = window.innerWidth,\n screenType;\n if (width <= 768) { screenType = \"mobile\"; } else if (width > 768 && width <= 992) {\n screenType = \"tablet\";\n } else {\n screenType = \"desktop\";\n }\n return screenType;\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Платформа

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"function () {return navigator.platform;}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Размер экрана

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"function() {\n var width = Math.max(document.documentElement.clientWidth, window.innerWidth || 0);\n var height = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);\n var screenSize = width + 'x' + height;\n return screenSize;\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Временная зона

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"function() {\n var timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n return timeZone;\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Проверка на Touch Screen

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"function isTouch() {\n try{ document.createEvent(\"TouchEvent\"); return true; }\n catch(e){ return false; }\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Юзер Агент

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"function () {return navigator.userAgent;}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Например, так выглядит парсинг ЮТМ меток (чтобы так же записать)

"}},{"type":"media","cover":false,"hidden":false,"anchor":"","data":{"items":[{"title":"","image":{"type":"image","data":{"uuid":"2bd3b160-f113-5464-90d6-1ab14cb02df5","width":925,"height":443,"size":23361,"type":"png","color":"f5f5f5","hash":"","external_service":[]}}}]}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Из полезного - метку не перехватить, если в SPA на запуске сразу срабатывает редирект. Надо либо передавать в рекламу конечную страницу, либо с разработкой делать проброс меток.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Вот так в переменную забрать свойства события, для каждого свойства - своя переменная

"}},{"type":"media","cover":false,"hidden":false,"anchor":"","data":{"items":[{"title":"","image":{"type":"image","data":{"uuid":"0b190335-589e-5184-ae13-7607953a370c","width":942,"height":303,"size":19408,"type":"png","color":"e4e6e6","hash":"","external_service":[]}}}]}}],"summaryContent":null,"isExistSummaryContent":false,"warningFromEditor":null,"warningFromEditorTitle":null,"counters":{"comments":2,"favorites":17,"reposts":0,"views":27,"hits":1964,"reads":null,"online":0},"dateFavorite":0,"hitsCount":1964,"isCommentsEnabled":true,"isLikesEnabled":true,"isRemovedByUserRequest":false,"isFavorited":false,"isPinned":false,"repostId":null,"repostData":null,"subscribedToTreads":false,"isEditorial":false,"isAudioAvailable":false,"audioUrl":null,"isAudioAvailableToGenerate":false,"commentEditor":{"enabled":true,"who":null,"text":"","until":null,"reason":null,"type":"everybody"},"isBlur":false,"isPublished":true,"isDisabledAd":false,"withheld":[],"ogTitle":null,"ogDescription":null,"url":"https://vc.ru/services/268754-produktovaya-analitika-v-odnostranichnom-veb-prilozhenii-spa-chast-1","author":{"id":150132,"name":"Dmitry Sedin","nickname":null,"description":null,"uri":"","avatar":{"type":"image","data":{"uuid":"38f53f44-594f-3669-8f9e-c737a4b57d6a","width":0,"height":0,"size":1,"type":"jpg","color":"","hash":"","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":5271790,"userId":150132,"count":0,"shareImage":"https://api.vc.ru/achievements/share/5271790"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":1647703,"userId":150132,"count":0,"shareImage":"https://api.vc.ru/achievements/share/1647703"},{"title":"5 лет на vc.ru","code":"registration_5_years","description":"Провёл 5 лет вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"a9140d54-73b8-5f40-afa8-449fbaafd42b","formats":{"glb":"https://static.vc.ru/achievements/whale.glb","usdz":"https://static.vc.ru/achievements/whale.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.533203125,"textY":0.658203125,"logoX":0.533203125,"logoY":0.77734375,"logoXNoText":0.4375,"logoYNoText":0.66015625},"id":420995,"userId":150132,"count":0,"shareImage":"https://api.vc.ru/achievements/share/420995"}],"lastModificationDate":1765084698,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"subsite":{"id":200396,"name":"Сервисы","description":"Новые сервисы, обновления инструментов, опыт использования и полезные приёмы.","uri":"/services","avatar":{"type":"image","data":{"uuid":"158fab2d-76c1-5ed8-898a-76ee48d4c795","width":1200,"height":1200,"size":99571,"type":"png","color":"7cdaea","hash":"08183848d81000","external_service":[]}},"cover":{"type":"image","data":{"uuid":"c3fe3abb-6808-527e-9eb1-2b6bb92ea400","width":3840,"height":1120,"size":19502,"type":"png","color":"7cdcec","hash":"","external_service":[]}},"lastModificationDate":1688995401,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":false,"isDisabledAd":false,"nickname":"services","isUnsubscribable":true,"badge":null,"badgeId":null,"isDonationsEnabled":false,"isOnline":false,"isPlus":false,"isUnverifiedBlogForCompanyWithoutPro":false,"isVerified":false,"isRemovedByUserRequest":false,"isFrozen":false,"isPro":false,"type":2,"subtype":"community"},"reactions":{"counters":[{"id":1,"count":4}],"reactionId":0},"isNews":false,"source":null,"clusters":[],"donations":{"amount":0,"isDonated":false},"commentsSeenCount":null,"keywords":[],"media":null,"customCover":null,"robotsTag":null,"categories":[],"isAnonymized":true}};