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.

Playwright
klika ścieżki użytkownika
↓
Strona produkcyjna
loader GTM zablokowany w sieci
↓dataLayer.push
Podsłuch
kopia każdego pusha w chwili wysłania
↓
Kontrakt pydantic
zgodne albo powód odrzucenia
↓
Raport
JSON → HTML i Markdown
Niebieskie — przeglądarka i strona. Bursztynowe — to, co audyt dokłada sam. GTM nie bierze udziału: dataLayer działa bez niego.

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%:

Audyt dataLayer
https://pmdata.pl · 2026-10-02 11:16 · Zgodny z planem
Pokrycie planu
100%8 z 8
zdarzenia z planu odpalone poprawnie co najmniej raz
Zgodność z kontraktem
100%84 z 84
pushe przechodzące walidację, łącznie z komendami gtag
Scenariusze zielone
100%12 z 12
scenariusze, w których kolejność i parametry są zgodne z planem
Nagłówek raportu z porannego przebiegu, 2 października 2026, odtworzony w HTML. Pod nim w raporcie: tabela zdarzeń z planu i rozwijane scenariusze z listą pushy.

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.

ElementZ dostępem do koduJako zewnętrzny analitykZamiennik
Źródło prawdyplik tracking planu w repotracking plan analityka, poza repoten sam plan przepisany na kontrakt — kontrakt należy do analityka
Kontrakt (pydantic)taktakzalecane zdarzenia e-commerce GA4 i zdarzenia własne; wspólne reguły dla items[], value, currency, transaction_id
Podsłuch dataLayertaktakwystarczy publiczny URL
Blokada GTMtaktak— (gdy chcesz testować też hity: osobna property albo debug_mode z aktywnym filtrem ruchu deweloperów)
Listing, karta produktu, koszyktaktakselektory z żywego DOM
Zakup (purchase)na stagingunie na produkcjistaging z płatnością testową
Drift z kodemtakniedrift z kontenerem GTM przez GTM API
Walidacja na danychopcjonalnietakreguły kontraktu na eksporcie GA4 do BigQuery
CyklicznośćGitHub Actionstylko 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:

  1. Kontrakt i walidacja danych GA4 w BigQuery — na danych historycznych, bez niczyjej zgody.
  2. Playwright na produkcji bez checkoutu, z blokadą GTM.
  3. Drift z kontenerem GTM przez API.
  4. 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.

  1. Sklonuj repo i wejdź do katalogu.
  2. make setup — instaluje Pythona 3.12, środowisko i Chromium, około minuty.
  3. cp .env.example .env i wpisz w .env wzorzec ś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.
  4. Podmień kontrakt w src/datalayer_audit/contract.py na własny tracking plan — jeden model na zdarzenie, reguły biznesowe jako walidatory. W tym samym kroku zaktualizuj EVENT_LABELS w report.py (import pada, gdy etykiety rozjadą się z kontraktem) i przepisz tests/test_contract.py.
  5. Ustaw adres swojej strony (base_url w pyproject.toml) i przepisz scenariusze w tests/e2e/test_scenarios.py na swoje ścieżki i selektory. Linki, których kliknięcie audyt anuluje (u mnie LinkedIn i tel:), siedzą w src/datalayer_audit/guard.py — dopisz tam swoje, np. WhatsApp czy Messenger.
  6. make check — lint, typy i testy bez wchodzenia na stronę. Test driftu bez mojego repo strony lokalnie się pomija.
  7. make audit-open — przebieg scenariuszy i raport HTML w przeglądarce (macOS; na Linuksie make audit, raport w reports/).

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.

Powiązane wpisy