Automatyczny audyt dataLayer — tracking plan jako kontrakt
Tracking nie psuje się z hukiem. Ktoś zmienia nazwę przycisku, refaktoruje komponent albo podmienia baner zgód. Strona działa, testy front-endu są zielone, a w GA4 znika konwersja albo parametr zaczyna przychodzić pusty. Wychodzi to po kilku tygodniach, w raporcie, kiedy tych danych nie da się już odzyskać.
Ręczny audyt w GTM Preview na małej stronie to kwadrans klikania. Na dużym kontenerze e-commerce, a na takich pracowałem, schodzi godzina albo dwie. Nikt tego nie powtarza po każdym deployu. Dlatego zbudowałem audyt, który robi to sam: przeglądarka codziennie rano przechodzi ścieżki użytkownika na mojej stronie, zbiera każdy push do dataLayer i sprawdza go względem tracking planu zapisanego jako kod. Projekt jest publiczny, na licencji MIT: datalayer-contract-audit na GitHubie.
Ostatnią część tekstu poświęcam pytaniu, które zadałem sobie zaraz po pierwszym zielonym raporcie: czy da się to zrobić u klienta e-commerce, do którego kodu nie mam dostępu? Da się — poza zakupem, do którego i tak potrzebny jest staging od IT.
Ręczny czy automatyczny audyt dataLayer
Cichą awarię trackingu opisywałem już przy okazji custom loadera GTM — strona wyglądała normalnie, a analityka nie istniała od doby. Tamten przypadek dotyczył całej warstwy tagowania. Ten dotyczy czegoś drobniejszego i częstszego: pojedynczego zdarzenia, które po zmianie w kodzie przestaje się wysyłać albo wysyła się z innym parametrem.
GTM Preview pokaże ci taki błąd, jeśli akurat klikniesz właściwą ścieżkę we właściwym momencie. Alert w GA4 pokaże go, jeśli spadek będzie wystarczająco duży i potrwa wystarczająco długo. Ani jedno, ani drugie nie odpowiada na proste pytanie zadawane po deployu: czy strona wysyła to, co obiecuje tracking plan?
Na to pytanie odpowiada dopiero automatyczny audyt dataLayer uruchamiany cyklicznie — z tracking planem, który da się wykonać, a nie tylko przeczytać.
Tracking plan jako kontrakt, nie arkusz
Tracking plan zwykle żyje w arkuszu albo na Confluence. U mnie żyje w kodzie strony, jako rejestr zdarzeń — ale i tak tylko opisuje intencję, niczego nie pilnuje. Przepisałem go na modele pydantic v2 — każde zdarzenie i każda komenda gtag() dostała typ, wymagane parametry i reguły biznesowe.
Dwa ustawienia robią tu całą robotę. strict=True wyłącza cichą konwersję typów: bez niego "true" przeszłoby jako bool, a "1" jako liczba, czyli kontrakt przepuściłby dokładnie te błędy implementacji, które ma łapać. extra="forbid" sprawia, że parametr spoza planu jest błędem, a nie ciekawostką.
class LinkedinClick(_Strict):
event: Literal["linkedin_click"]
link_text: str = Field(min_length=1)
link_url: str
@field_validator("link_url")
@classmethod
def _must_point_to_linkedin(cls, value: str) -> str:
# Sparsowany host, nie podciąg: "linkedin.com" in url
# przepuściłoby https://linkedin.com.evil.example
host = urlsplit(value).hostname or ""
if not (host == "linkedin.com"
or host.endswith(".linkedin.com")):
raise ValueError(
f"link_url musi wskazywać linkedin.com, "
f"wskazuje {host!r}"
)
return value
Najwięcej daje mi to przy Consent Mode. Reguła „przed decyzją użytkownika wszystkie zgody poza security_storage są denied” jest zapisana w typach — Literal["denied"] zamiast dowolnego stanu — więc regresja typu analytics_storage: 'granted' w stanie domyślnym wywala walidację. Druga reguła pilnuje relacji między polami: baner ma jeden przełącznik marketingowy, a ten mapuje się na trzy sygnały ad_*. Jeśli po aktualizacji zgód mają różne stany, to jest błąd w mapowaniu, nie świadomy wybór odwiedzającego.
To samo podejście opisywałem od strony organizacyjnej w tekście o data governance w analityce: tracking plan jest kontraktem między zespołami. Tu ten kontrakt da się uruchomić.
Podsłuch dataLayer i testy trackingu w Playwright
Kontrakt potrzebuje danych. Zbiera je Playwright, w trzech krokach.
klika ścieżki użytkownika
loader GTM zablokowany w sieci
kopia każdego pusha w chwili wysłania
zgodne albo powód odrzucenia
JSON → HTML i Markdown
Pierwszy krok to podsłuch. Playwright ma add_init_script — skrypt wstrzyknięty do dokumentu przed pierwszym skryptem strony. Tworzy tablicę dataLayer, owija jej push i zapisuje kopię każdego pusha. Strona robi potem standardowe window.dataLayer = window.dataLayer || [], więc przejmuje już podsłuchaną tablicę i nie widzi różnicy. Kopia, a nie referencja, bo strona może później zmutować obiekt — audyt widziałby wtedy stan z chwili odczytu, nie z chwili wysłania.
Drugi krok to scenariusze. Jest ich 12 i każdy startuje w świeżym kontekście przeglądarki, z pustym localStorage: kolejność zgód przy wczytaniu strony, cztery decyzje w banerze (akceptacja, odrzucenie, tylko analityka, tylko marketing), powracający użytkownik, telefon, dwa CTA LinkedIn, czat, menu mobilne i stopka artykułu. Lokalnie całość trwa około 10 sekund.
Trzeci krok to walidacja. Każdy push przechodzi przez kontrakt, a scenariusz sprawdza dodatkowo kolejność — na przykład czy consent default poszedł przed snippetem GTM.
Audyt na produkcji bez jednego hitu w GA4
Moja strona nie ma wersji testowej. Audyt chodzi po produkcji, więc musiał spełniać jeden warunek: nie zostawić śladu w danych.
- Loader GTM jest blokowany na poziomie sieci. Żądanie kończy się błędem, zanim opuści przeglądarkę. dataLayer działa bez GTM, więc kontrakt da się sprawdzić bez wysłania czegokolwiek do GA4.
- Kliknięcia w linki wychodzące są anulowane. LinkedIn i
tel:— strona wysyła zdarzenie, ale przejście nie następuje. - Czat z AI jest tylko otwierany. Nigdy nie dostaje wiadomości, więc nie zużywa limitu ani budżetu API.
Cena jest jedna: audyt dataLayer to nie audyt hitów GA4. Projekt sprawdza warstwę źródłową. To, czy GTM poprawnie przekłada zdarzenia na hity, czy tagi mają dobre triggery i czy parametry trafiają do właściwych wymiarów — to osobny temat i świadomie zostawiłem go poza zakresem.
Cztery pułapki z budowy
gtag() pushuje obiekt arguments, nie tablicę. JSON.stringify robi z niego {"0":"consent","1":"default",…} i kontrakt nie rozpoznaje komendy. Bez zamiany na tablicę sygnały Consent Mode znikają z audytu — bez błędu, bo nie ma czego walidować. Podsłuch zamienia teraz arguments na tablicę przez Array.from, a kontrakt rozpoznaje kształt pusha funkcją, bo komendy gtag nie mają pola event, po którym działa zwykły dyskryminator pydantic.
Zablokowana nawigacja zabija audyt. Pierwszy odruch: zablokować żądanie do LinkedIna w sieci. Chromium pokazuje wtedy w tej samej karcie stronę błędu, a podsłuch ginie razem z dokumentem. Działa dopiero anulowanie samego kliknięcia (preventDefault) — strona pushuje zdarzenie, przejście nie startuje.
Strona może nadpisać dataLayer nową tablicą. Wtedy dalsze pushe omijają podsłuch i audyt jest fałszywie zielony: zero pushy, zero błędów. Podsłuch oznacza swoją funkcję push, a test sprawdza, czy pod window.dataLayer dalej siedzi oznaczona wersja.
Jeden procent kłamie. 7 z 8 zdarzeń to 87,5% — liczba, przy której nikt nie podnosi alarmu. Nawet gdy brakuje akurat zgody marketingowej, od której zależą wszystkie kampanie. Dlatego raport pokazuje trzy osobne wskaźniki, każdy z liczbą obok procentu.
Raport: trzy liczby zamiast jednego procentu
Raport ma jedno źródło — JSON — i dwa widoki, HTML i Markdown. Werdykt „zgodny z planem” pada tylko wtedy, gdy wszystkie trzy wskaźniki mają 100%:
Każdy wskaźnik pokazuje liczby, nie tylko procent. Nieudany scenariusz rozwija się sam, z listą pushy, czasem każdego w milisekundach od wczytania strony i komunikatem pydantic, który mówi dokładnie, które pole i dlaczego.
Codziennie rano, bez mojego udziału
GitHub Actions uruchamia audyt codziennie o 8:00 (zimą o 7:00, bo cron liczy w UTC). Raport ląduje w podsumowaniu joba, pełny HTML jako artefakt. Czerwony przebieg GitHub zgłasza mailem. Repo jest publiczne, więc minuty są darmowe — całość kosztuje zero złotych.
Osobny job, bez wchodzenia na stronę, porównuje kontrakt z tracking planem w kodzie strony. Dodam nowe zdarzenie do strony i zapomnę o modelu w kontrakcie — najbliższy poranny przebieg CI robi się czerwony. To jest drift: rozjazd między tym, co strona wysyła, a tym, co audyt umie sprawdzić.
Stack, w skrócie i z powodem: Playwright, bo wstrzyknięcie skryptu przed kodem strony i blokadę żądań ma wbudowane, a w Selenium to zejście do CDP i więcej kodu. Pydantic v2, bo kontrakt ma być kodem, który się uruchamia. Pytest, Jinja2 z autoescape (tekst ze strony nie wykona się w raporcie), uv, ruff i mypy w trybie strict. Makefile jako jedyny interfejs: make check lokalnie i w CI to ta sama komenda.
Tracking plan e-commerce bez dostępu do kodu klienta
Moja strona to wygodny przypadek: mam kod, tracking plan i kontrolę nad deployem. U klienta e-commerce tracking plan zwykle i tak jest mój — często to ja go projektuję, a jeśli go nie ma, zaczynam od jego napisania. Brakuje mi czego innego: dostępu do repozytorium i wpływu na to, co i kiedy trafia na produkcję. Rozpisałem więc, co z tego projektu przenosi się bez zmian, a co trzeba zastąpić. Kontrakt dla dataLayer GA4 e-commerce obejmuje kilkanaście zalecanych zdarzeń z dokumentacji GA4 — od view_item_list i select_item, przez koszyk i checkout, po purchase i refund — plus zdarzenia własne, które każdy sklep dokłada pod swoje procesy. Każde dostaje osobny model.
| Element | Z dostępem do kodu | Jako zewnętrzny analityk | Zamiennik |
|---|---|---|---|
| Źródło prawdy | plik tracking planu w repo | tracking plan analityka, poza repo | ten sam plan przepisany na kontrakt — kontrakt należy do analityka |
| Kontrakt (pydantic) | tak | tak | zalecane zdarzenia e-commerce GA4 i zdarzenia własne; wspólne reguły dla items[], value, currency, transaction_id |
| Podsłuch dataLayer | tak | tak | wystarczy publiczny URL |
| Blokada GTM | tak | tak | — (gdy chcesz testować też hity: osobna property albo debug_mode z aktywnym filtrem ruchu deweloperów) |
| Listing, karta produktu, koszyk | tak | tak | selektory z żywego DOM |
Zakup (purchase) | na stagingu | nie na produkcji | staging z płatnością testową |
| Drift z kodem | tak | nie | drift z kontenerem GTM przez GTM API |
| Walidacja na danych | opcjonalnie | tak | reguły kontraktu na eksporcie GA4 do BigQuery |
| Cykliczność | GitHub Actions | tylko za zgodą | to ruch na produkcji klienta |
Dwa wiersze zmieniają tu najwięcej.
Pierwszy to zakup. purchase jest najważniejszym zdarzeniem w e-commerce i jedynym, którego nie przetestujesz na produkcji. Nie złożysz zamówienia, nie zapłacisz, nie wyślesz formularza. Staging z płatnością testową to jedyna rzecz, o którą trzeba poprosić IT. Poproś o nią na samym początku — takie prośby schodzą najdłużej.
Drugi to walidacja na danych — tu analityk ma przewagę nad developerem. Te same reguły kontraktu da się sprawdzić na eksporcie GA4 w BigQuery — po spłaszczeniu event_params i items do kształtu, który zna model. To zresztą domyka lukę z wcześniejszej sekcji: dataLayer sprawdza przeglądarka, hity sprawdza eksport. Brak transaction_id, zduplikowane purchase, value różne od sumy items — wszystko to widać w danych historycznych. Tak samo jak purchase odpalany przed potwierdzeniem płatności, który opisywałem przy błędach wdrożenia GA4 e-commerce. Bez niczyjej zgody i bez wchodzenia na stronę.
Ryzyka są tu bardziej organizacyjne niż techniczne:
- Pisemna zgoda właściciela strony. Bez niej nie uruchamiałbym niczego cyklicznie. WAF potrafi zablokować IP biura, a wtedy tłumaczysz się z ruchu, którego nikt nie zamawiał.
- Testy A/B zmieniają DOM. Selektor, który działał wczoraj, dziś trafia w wariant B. Scenariusz pada, choć tracking jest w porządku.
- Zero zakupów i formularzy na produkcji. Bez wyjątków.
- Blokada GTM nie czyni ruchu niewidzialnym. Wizyty trafiają do logów serwera i do systemów antyfraudowych — ktoś po stronie klienta musi wiedzieć, skąd się biorą.
Kolejność wdrożenia u klienta, od najtańszej do najdroższej w uzgodnieniach:
- Kontrakt i walidacja danych GA4 w BigQuery — na danych historycznych, bez niczyjej zgody.
- Playwright na produkcji bez checkoutu, z blokadą GTM.
- Drift z kontenerem GTM przez API.
- Staging dla
purchase.
Pierwszy krok nie wymaga niczyjej zgody, a jego wynik jest najlepszym argumentem za kolejnymi.
Jak uruchomić audyt u siebie
Kod jest na GitHubie: pmackowka/datalayer-contract-audit, licencja MIT. To nie biblioteka z plikiem konfiguracyjnym, tylko działający wzorzec napisany pod jedną stronę. Do uruchomienia u siebie trzeba go przerobić. Wymagane: uv i make.
- Sklonuj repo i wejdź do katalogu.
make setup— instaluje Pythona 3.12, środowisko i Chromium, około minuty.cp .env.example .envi wpisz w.envwzorzec ścieżki loadera GTM, który audyt ma blokować. Plik jest poza gitem celowo: w publicznym repo ścieżka first-party loadera byłaby gotowym wpisem dla list blokujących reklamy.- Podmień kontrakt w
src/datalayer_audit/contract.pyna własny tracking plan — jeden model na zdarzenie, reguły biznesowe jako walidatory. W tym samym kroku zaktualizujEVENT_LABELSwreport.py(import pada, gdy etykiety rozjadą się z kontraktem) i przepisztests/test_contract.py. - Ustaw adres swojej strony (
base_urlwpyproject.toml) i przepisz scenariusze wtests/e2e/test_scenarios.pyna swoje ścieżki i selektory. Linki, których kliknięcie audyt anuluje (u mnie LinkedIn itel:), siedzą wsrc/datalayer_audit/guard.py— dopisz tam swoje, np. WhatsApp czy Messenger. make check— lint, typy i testy bez wchodzenia na stronę. Test driftu bez mojego repo strony lokalnie się pomija.make audit-open— przebieg scenariuszy i raport HTML w przeglądarce (macOS; na Linuksiemake audit, raport wreports/).
Cykliczność to plik .github/workflows/ci.yml: wzorzec ścieżki loadera idzie tam jako sekret repozytorium. Z joba quality usuń krok pobierający mój tracking plan (inaczej CI będzie czerwony) albo wskaż TRACKING_PLAN_PATH na własny plik. Cron ustawiasz w UTC.
Co z tego wynika
Najwięcej dało mi nie samo narzędzie, tylko zmiana pytania. Zamiast „czy dane w GA4 wyglądają normalnie” pytam „czy dataLayer spełnia kontrakt” — i to pytanie ma odpowiedź tego samego dnia, a nie po miesiącu, w raporcie.