LW IT Solutions logo LW IT Solutions logo LW IT Solutions
« Przegląd bloga /Digital Analytics/Tutorials / Przesyłanie zakupów sklepowych do GA4 przez Measurement...
Ten artykuł w innych językach:

Przesyłanie zakupów sklepowych do GA4 przez Measurement Protocol: numer karty lojalnościowej jako klucz

Przesyłanie zakupów sklepowych do GA4 przez Measurement Protocol: numer karty lojalnościowej jako klucz
Spis treści
  1. Co trzeba połączyć
  2. Numer karty zostaje zahaszowany, zanim gdziekolwiek trafi
  3. Zapis powiązania, dopóki klient jest online
  4. Tabela powiązań
  5. client_id dla osób, które nigdy nie były online
  6. Ładunek, pole po polu
  7. Znacznik czasu i okno otwarte tylko z jednej strony
  8. Jedno żądanie na klienta i dlaczego łączenie w paczki działa tu inaczej
  9. Sprawdzenie przed wysyłką, bo produkcja nigdy nie odpowiada
  10. Czego sprawdzenie nie wychwytuje
  11. Rytm wysyłki i cena przebiegu tygodniowego
  12. Czego to nie rozwiązuje
  13. Pytania i odpowiedzi
  14. Źródła

Kasa sklepowa wie o sprzedaży trzy rzeczy: co zostało kupione, ile to kosztowało i która karta lojalnościowa przeszła przez czytnik przy ladzie. GA4 nie wie o tym nic. GA4 zna przeglądarki i rozpoznaje je po wartości cookie, która w sklepie nigdy nie istniała.

Measurement Protocol to jedyna droga, którą wiedza kasy trafia do tej samej usługi co dane witryny. To zwykły POST po HTTPS, nie wymaga żadnej biblioteki i przyjmuje niemal wszystko — właśnie dlatego tę drogę trzeba zbudować starannie, a nie szybko.

Łańcuch od numeru karty lojalnościowej przez skrót i tabelę powiązań do ładunku Measurement Protocol, a pod nim oś czasu z przyjmowanym oknem, jego zmierzonymi granicami i udziałem paragonów traconych przy trzech rytmach wysyłki
Łańcuch od numeru karty do ładunku oraz okno, w które musi trafić znacznik czasu.

Co trzeba połączyć

Dwa pola ładunku Measurement Protocol niosą tożsamość i nie są wymienne.

client_id to tożsamość urządzenia. Tę wartość tag GA4 zapisuje w przeglądarce w cookie _ga i jest ona obowiązkowa — ładunek bez niej serwer walidacyjny odrzucił komunikatem VALUE_REQUIRED, nawet gdy obecne było user_id. user_id to tożsamość osoby, pole opcjonalne, które przetrwa zmianę urządzenia.

Numer karty lojalnościowej nie jest ani jednym, ani drugim. To trzeci identyfikator, istniejący wyłącznie w sklepie, a cała praca polega na przekształceniu go w tamte dwa.

Numer karty zostaje zahaszowany, zanim gdziekolwiek trafi

Numer karty wskazuje osobę i należy do relacji z klientem. Wysłanie go otwartym tekstem do usługi analitycznej zamienia identyfikator sklepowy w trwały wpis na cudzej infrastrukturze, a GA4 wprost zabrania przesyłania danych umożliwiających identyfikację.

Skrót z kluczem rozwiązuje oba problemy naraz. Pieprz to sekret pozostający na własnym serwerze; bez niego skrótu nie da się odtworzyć ze skradzionej listy numerów kart.

PHP
function card_key(string $card, string $pepper): string {
    // Najpierw ujednolicenie: kasa zapisuje "LW 4471 9930",
    // formularz na stronie "lw-4471-9930". Bez tego kroku obie
    // strony haszuja inaczej i nic nigdy sie nie polaczy.
    $clean = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $card));
    return hash_hmac('sha256', $clean, $pepper);
}

To ujednolicenie jest miejscem, w którym integracje psują się po cichu. Dwa systemy zapisujące tę samą kartę inaczej dają dwa różne skróty, a połączenie danych zawodzi potem bez komunikatu w jakimkolwiek miejscu.

Zapis powiązania, dopóki klient jest online

Most powstaje raz, w przeglądarce, w jedynym momencie, gdy oba identyfikatory znajdują się w tym samym miejscu: gdy numer karty zostaje wpisany na koncie klienta albo powiązany przy zakupie online.

Wartość client_id jest częścią wartości cookie _ga. Poniższy kod odczytuje ją, łącząc dwa ostatnie segmenty kropką.

PHP
// _ga wyglada jak GA1.1.1899482740.1757000000
// client_id to dwa ostatnie odcinki polaczone kropka.
function ga_client_id(): ?string {
    if (empty($_COOKIE['_ga'])) { return null; }
    $parts = explode('.', $_COOKIE['_ga']);
    if (count($parts) < 4) { return null; }
    return $parts[count($parts) - 2] . '.' . $parts[count($parts) - 1];
}

W tym samym momencie witryna ustawia ten sam skrót jako user_id w strumieniu internetowym. Jeżeli witryna i program wysyłający wyliczą tu różne wartości, GA4 otrzyma dwie obce sobie osoby, a żadna ze stron nie zgłosi błędu.

Tabela powiązań

Jedna karta należy do jednej osoby, ale ta osoba może korzystać z kilku przeglądarek. Tabela musi więc przechowywać więcej niż jedno powiązanie dla danej karty, a program wysyłający potrzebuje reguły wyboru wiersza.

SQL
CREATE TABLE card_pairing (
  card_hash  CHAR(64)    NOT NULL,
  client_id  VARCHAR(64) NOT NULL,
  first_seen DATETIME    NOT NULL,
  last_seen  DATETIME    NOT NULL,
  PRIMARY KEY (card_hash, client_id),
  KEY by_time (card_hash, last_seen)
) ENGINE=InnoDB;

Najlepiej sprawdza się powiązanie widziane ostatnio: ORDER BY last_seen DESC LIMIT 1. Telefon z zeszłego tygodnia to lepsze przypuszczenie niż komputer używany raz w roku; najstarszy wiersz przypina zakup do urządzenia, którego dawno już może nie być.

client_id dla osób, które nigdy nie były online

W sklepie większość kart nie ma żadnego powiązania. Te paragony też muszą gdzieś trafić, a wymyślenie client_id jest dozwolone — lecz nie w dowolnej postaci.

Serwer walidacyjny wymusza format. Pomiar z 9 września 2026 roku, z ustawionym ENFORCE_RECOMMENDATIONS: card-LW-4471-9930, dwudziestoznakowy przedrostek skrótu oraz pojedyncza cyfra 7 zostały odrzucone komunikatem client_id [...] has invalid format. It should be in <number>.<number> format. Przecinek zamiast kropki został odrzucony, trzeci odcinek również. Przyjęte zostały: 1.1, 0.0, dwudziestocyfrowe liczby po obu stronach oraz — nieoczekiwanie — 1899482740. i .1757000000, każde z brakującą połową.

Stałe wyliczenie ze skrótu karty spełnia format i pozostaje niezmienne między uruchomieniami.

PHP
// Dwie liczby 32-bitowe z pierwszych 16 znakow szesnastkowych.
$h   = card_key($card, $pepper);
$cid = hexdec(substr($h, 0, 8)) . '.' . hexdec(substr($h, 8, 8));
// LW-4471-9930 -> 2531008582.2372831494, przyjete przez serwer walidacyjny

Co to daje, a czego nie: paragony jednej karty trafiają teraz na jedną stałą tożsamość, zamiast się rozpraszać. Z sesjami internetowymi tej samej osoby nie zrosną się, bo wyliczona wartość nie pasuje do żadnego cookie _ga na świecie. To połączenie niesie wyłącznie user_id, i tylko w usłudze, której tożsamość raportowania może z niego korzystać.

Ładunek, pole po polu

Dwa dane dostępowe w ciągu zapytania, cała reszta w treści JSON. measurement_id i api_secret nigdy nie należą do treści — tam stają się nieznanymi polami, a nieznane pole nie zostaje pominięte, tylko unieważnia cały ładunek.

HTTP
POST /mp/collect?measurement_id=G-XXXXXXXXXX&api_secret=SECRET HTTP/1.1
Host: www.google-analytics.com
Content-Type: application/json

{
  "client_id": "2531008582.2372831494",
  "user_id": "9f2c4b1e8a7d3c5f0b6e2a9d4c8f1b3e7a0d5c2f9b4e8a1d6c3f0b7e2a5d9c4f",
  "consent": { "ad_user_data": "GRANTED", "ad_personalization": "DENIED" },
  "events": [
    {
      "name": "purchase",
      "timestamp_micros": "1792301400000000",
      "params": {
        "transaction_id": "HH-ALT-2026-1016-0007",
        "value": 48.90,
        "currency": "EUR",
        "affiliation": "Sklep Hamburg-Altona",
        "session_id": "1792301400",
        "engagement_time_msec": "1",
        "items": [
          { "item_id": "SKU-3391", "item_name": "Mlynek do kawy reczny",
            "price": 48.90, "quantity": 1 }
        ]
      }
    }
  ]
}

transaction_id to numer paragonu i jedyna rzecz, która powstrzymuje powtórzoną wysyłkę przed policzeniem sprzedaży dwa razy. affiliation niesie filię — pole, które dopiero czyni te dane wartościowymi. session_id oraz engagement_time_msec przypinają zdarzenie do sesji w raportach; protokół ich nie wymaga, a ich brak nie jest nigdzie zgłaszany.

Dwie reguły typów, obie zmierzone. Wartość price pozycji podana jako tekst "48.90" zostaje przyjęta, "48,90" odrzucona komunikatem Known item param [price] should have type [int64] or [double], podobnie "abc" i pusty ciąg. Pole value na poziomie zdarzenia nie jest sprawdzane tak samo: tam "abc" przeszło bez słowa. Kwota sformatowana lokalnie przez kasę zawodzi więc w jednym miejscu, a w drugim przechodzi po cichu.

Znacznik czasu i okno otwarte tylko z jednej strony

To pole rozstrzyga, czy wysyłka danych offline w ogóle zadziała, a obie jego krawędzie zostały zmierzone, a nie przepisane z dokumentacji.

Odstęp od terazWerdykt serwera walidacyjnego
71 h 59 min 55 s wsteczprzyjęty
72 h 00 min 00 s wsteczodrzucony, VALUE_INVALID
0 s — bieżąca sekundaprzyjęty
5 s w przyszłośćodrzucony, VALUE_INVALID
15 min w przyszłośćodrzucony

Krawędź wsteczna jest dokładna i wykluczająca: 72 godziny co do sekundy są już za stare. Krawędź przednia to niespodzianka. Treść komunikatu brzmi Measurement protocol only supports timestamps [15m] into the future, odrzucona została jednak każda sprawdzona wartość w przód, aż po pięć sekund. Zegar maszyny wysyłającej mieścił się w tym samym przebiegu w granicy 0,8 sekundy od nagłówka Date zwracanego przez Google — po stronie pomiaru przyczyny więc nie ma.

Dla sklepu oznacza to, że zegar kasy jest bieżącym ryzykiem. Terminal spieszący się o minutę tworzy paragony, które protokół traktuje jako zdarzenia z przyszłości, a produkcyjny endpoint i tak przyjmuje je z kodem 204. Program wysyłający powinien zestawiać czas paragonu z własnym, wiarygodnym zegarem i zatrzymywać wszystko datowane w przód, zamiast to wysyłać.

Wartość musi być podana w mikrosekundach. Znacznik w sekundach zostaje odczytany jako data z roku 1970 i powoduje komunikat too far in the past. Może on sugerować problem z antydatowaniem, choć faktyczną przyczyną jest błędna jednostka czasu.

Jedno żądanie na klienta i dlaczego łączenie w paczki działa tu inaczej

client_id oraz user_id stoją na najwyższym poziomie ładunku, obok events, a nie wewnątrz zdarzenia. Przeniesienie client_id do zdarzenia zostało odrzucone wprost: ładunek przestał być czytelny, z komunikatem invalid JSON in google.analytics.measurement.Measurement @ events[1]. Przy user_id wewnątrz zdarzenia stało się to samo.

Jedno żądanie nie może więc mieszać klientów. Spakowanie dwudziestu pięciu paragonów od dwudziestu pięciu posiadaczy kart w jedno wywołanie jest niemożliwe, a jednostką pracy programu wysyłającego jest klient, nie paczka.

Działa natomiast znacznik czasu na poziomie zdarzenia: timestamp_micros wewnątrz zdarzenia zostało przyjęte, także w ładunku ze znacznikiem górnym sprzed godziny i drugim zdarzeniem sprzed dwóch godzin. Jedno żądanie może zatem nieść wszystkie paragony jednej karty z całego okresu, każdy z własnym czasem.

Udokumentowany pułap to dwadzieścia pięć zdarzeń i 130 kB na żądanie. Serwer walidacyjny nie wymusza żadnego z nich — dwadzieścia sześć zdarzeń przeszło, a sto paragonów po dwie pozycje dało 28,8 kB i również przeszło — ale limit udokumentowany i niewymuszany pozostaje limitem, a skutki widać w raportach.

Sprawdzenie przed wysyłką, bo produkcja nigdy nie odpowiada

Produkcyjny endpoint zwraca na wszystko 204 No Content. Zmierzone ładunkami z tego artykułu: poprawny paragon, paragon sprzed trzydziestu dni, znacznik czasu w sekundach zamiast mikrosekund, czteroliterowy kod waluty oraz ładunek zupełnie bez client_id — wszystkie 204, wszystkie poniżej 220 milisekund, wszystkie z pustą treścią. Przypadek bez client_id jest tym wymownym, bo dokładnie ten ładunek serwer walidacyjny odrzuca.

Endpoint walidacyjny to ten sam adres z /debug przed ścieżką. Nie sprawdza ani measurement_id, ani api_secret, ładunek da się więc sprawdzić bez wynoszenia jakiegokolwiek sekretu poza firmę.

Shell
curl -s -X POST \
  'https://www.google-analytics.com/debug/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret=zastepnik' \
  -H 'Content-Type: application/json' \
  -d '{"client_id":"2531008582.2372831494",
       "validation_behavior":"ENFORCE_RECOMMENDATIONS",
       "events":[{"name":"purchase","params":{"currency":"EUR","value":48.90}}]}'

validation_behavior należy do sprawdzenia i do niczego innego. Wysłane na produkcyjny endpoint jest nieznanym polem, a nieznane pole unieważnia dokładnie ten ładunek, który miało zabezpieczyć.

Jedna cecha serwera walidacyjnego kształtuje sposób korzystania z niego: zgłasza on tylko pierwsze znalezisko. Trzy problemy w jednym zdarzeniu dają jeden komunikat, a drugi pojawia się dopiero po usunięciu pierwszego. Świeżo zbudowany ładunek potrzebuje więc kilku rund — właśnie po to istnieje walidator wypisujący wszystkie błędy naraz.

Czego sprawdzenie nie wychwytuje

Z ustawionym ENFORCE_RECOMMENDATIONS wszystko poniższe przeszło bez jednego komunikatu: purchase bez transaction_id, bez value, bez currency, bez items, z pustą tablicą items i zupełnie bez parametrów. Dwa zdarzenia z tym samym transaction_id w jednym żądaniu przeszły. user_id o długości 257 znaków przeszło, o jeden znak ponad udokumentowane maksimum. Kod waluty EURO przeszedł, małymi literami eur również.

Nic z tego nie jest błędem walidatora; to granica tego, co kontrola protokołu może wiedzieć. Czy paragon ma numer, czy kwota jest prawdziwą kwotą i czy ta sama sprzedaż została już wysłana — to pytania o dane sklepu i odpowiedzi muszą istnieć, zanim powstanie ładunek.

Rytm wysyłki i cena przebiegu tygodniowego

Okno 72 godzin zamienia harmonogram w decyzję projektową. Dla programu uruchamianego o 03:00 i obejmującego całe dni:

RytmNajstarszy paragon przy wysyłceUdział poza oknem
co noc27 h0 %
co trzy dni75 h4,2 %
co tydzień171 h58,9 %

Przebieg tygodniowy traci ponad połowę tego, co niesie, i traci to bezgłośnie: endpoint odpowiada odrzuconym paragonom dokładnie tym samym kodem 204 co przyjętym. Przebieg nocny ma 45 godzin zapasu i dopiero ten zapas czyni nieudaną noc powtarzalną — pominięty przebieg da się nadrobić następnego dnia i wszystko mimo to dotrze.

Ten zapas czasu przemawia też za przechowywaniem nieudanych wysyłek w kolejce. Każdy niewysłany paragon zachowuje własny termin. Zapisanie jego czasu w kolejce pozwala ustalić, które paragony nadal mieszczą się w oknie wysyłki, a które są już zbyt stare.

Czego to nie rozwiązuje

Trzy granice warto nazwać wprost.

Powyższe pomiary opisują to, co przyjmuje protokół. Czy połączony zakup pojawi się potem w konkretnym raporcie, zależy od tożsamości raportowania usługi i od tego, czy witryna w ogóle zbiera user_id — to kwestia ustawień GA4, nie protokołu, i nic z tego nie było tu mierzone.

Zwroty i anulowania nie mają na tej drodze wygodnego miejsca. Zdarzenie refund istnieje, ale zwrot przychodzący cztery dni po sprzedaży wypada z okna dokładnie tak samo jak spóźniony paragon.

Czego okno nie dopuszcza, na to pozwala inna droga. Paragony starsze niż trzy dni, zwroty przychodzące tydzień później i każde uzupełnianie przeszłości nie należą wcale do tego protokołu: przy usłudze z włączonym eksportem do BigQuery należą do złączenia z surowymi tabelami events_*, po tych samych identyfikatorach, które buduje ten artykuł. Ta droga nie zna limitu czasu. Jest też powodem, dla którego krawędź 72 godzin pozostaje kwestią harmonogramu, a nie trwałą stratą — dopóki same paragony są przechowywane.

Sam protokół jest zaś zamknięty. Google przeniósł Measurement Protocol w czerwcu 2026 roku do stanu ostatecznego — bez wyłączenia i bez nowych funkcji — i kieruje nowe integracje serwer-serwer do Data Manager API. Dla sklepu, którego kasa już eksportuje paragony, nie jest to nagły wypadek: zmierzone tu granice są granicami, które pozostaną.

Pytania i odpowiedzi

Co się dzieje, gdy trzeba zmienić pieprz, na przykład po wycieku?

Zmienia się każdy skrót, a wraz z nim tego dnia urywa się historia osób w GA4. Dotyczy to trzech miejsc:

  1. user_id. Witryna i program wysyłający liczą je tym samym pieprzem. Jeśli przełączy się tylko jedna z nich, powstają dokładnie te dwie obce sobie osoby, przed którymi ostrzega artykuł. Obie strony trzeba więc przestawić w tej samej chwili.
  2. Tabela powiązań. Jej klucze to stare skróty, a HMAC nie da się odwrócić. Tabelę da się odbudować tylko w ten sposób, że każdy numer karty z bazy klientów zostaje zahaszowany starym i nowym pieprzem, a stary klucz zostaje zastąpiony nowym.
  3. Wyliczone client_id. One również pochodzą ze skrótu. Każda karta bez powiązania pojawia się od chwili zmiany jako nowa tożsamość, a paragony sprzed zmiany i po niej się nie łączą.

Zmiana pieprzu przypomina więc bardziej migrację danych niż rutynową konserwację. Pieprz znajduje się wszędzie tam, gdzie liczony jest skrót, czyli w witrynie i w programie wysyłającym. W obu miejscach musi być równie dobrze chroniony.

Dlaczego skrót z kluczem, a nie po prostu SHA-256 z numeru karty?

Bo numerów kart jest za mało. Przy założonym formacie złożonym ze stałego przedrostka i ośmiu cyfr, jak w przykładzie LW 4471 9930, możliwych jest tylko 100 milionów numerów; zahaszowanie ich wszystkich bez klucza i przypisanie każdego skrótu do jego numeru nie trwa na zwykłym sprzęcie długo. Pieprz uniemożliwia takie wyliczenie, dopóki pozostaje tajny.

Po czym program wysyłający rozpoznaje nieudaną wysyłkę, skoro produkcyjny endpoint zawsze odpowiada 204?

Tylko po samym połączeniu: jeśli nie przychodzi odpowiedź, połączenie się zrywa albo pojawia się inny status niż 204, paragon trafia do kolejki. Wszystko, co dotyczy treści, musi być rozstrzygnięte przed wysyłką, bo endpoint niczego potem nie zgłosi. Do samego programu wysyłającego należą więc cztery kontrole:

  • Czas paragonu w porównaniu z własnym zegarem: to, co jest starsze niż 72 godziny, nie należy już do tego protokołu, tylko do drogi przez BigQuery, a to, co datowane jest w przód, zostaje wstrzymane.
  • Formaty: ceny jako liczba z kropką dziesiętną, client_id w postaci <liczba>.<liczba>, znacznik czasu w mikrosekundach.
  • Pola, których brak serwer walidacyjny przepuszcza bez komunikatu: transaction_id, value, currency i items.
  • Każda nowa postać ładunku raz przez endpoint walidacyjny, w tylu rundach, aż nie pojawi się żaden komunikat, bo zgłasza on tylko pierwsze znalezisko.

Czy paragony naprawdę dotarły, da się sprawdzić dopiero w danych samej usługi, przy włączonym eksporcie do BigQuery na przykład po numerach paragonów.

Lukas Wójcik

Lukas Wójcik

Systems architect and technology enthusiast specializing in scalable tracking solutions, GMP Stack (GA4 & GTM), and robust backend architectures. Advocate for clean code and privacy-first design.

Kontakt

Masz pytanie lub pomysł na projekt internetowy? Napisz krótko, o co chodzi. Formularz korzysta z reCAPTCHA do ochrony przed spamem.

Napisanie komentarza

Odmienne wyniki z innych kont i pytania o konfigurację są tu mile widziane.

Adres e-mail nie jest publikowany. Pola obowiązkowe oznaczono gwiazdką.