Atrybucja web to app — gdzie Smart Script AppsFlyera gubi kampanię
- Atrybucja
- AppsFlyer
- GTM
- E-commerce
Klientka klika w poniedziałek reklamę w Google, ogląda kurtki na telefonie i wychodzi. W czwartek wraca, wpisując adres sklepu. Na stronie widzi baner „Pobierz aplikację i odbierz rabat na pierwsze zamówienie”, instaluje ją i kupuje w aplikacji. W AppsFlyerze ta instalacja to ruch z www albo organic. Kampania z poniedziałku nie dostaje nic, choć to ona przyprowadziła klientkę.
W e-commerce z aplikacją mobilną to nie jest przypadek brzegowy. Strona mobilna jest dla wielu osób pierwszym kontaktem, a baner do sklepu z aplikacjami — najtańszym źródłem instalacji. Jeśli granica web → app kasuje źródło, budżet przesuwa się z kampanii, które realnie dowożą instalacje, do kanałów, które tylko zbierają ostatni klik.
AppsFlyer ma na to oficjalne narzędzie: OneLink Smart Script V2. Działa, ale pamięta krótko. Ja zapamiętuję źródło na tygodnie i to jest cała różnica, która decyduje o atrybucji.
Pierwszą wersję tego tagu pisałem dla e-commerce z aplikacją mobilną, jeszcze w czasach Universal Analytics i Firebase Dynamic Links. Niedawno odtworzyłem go z poprawkami błędów i testami. Opisuję mechanizm ogólnie, bez kodu produkcyjnego i bez odwołań do konkretnego wdrożenia.
Atrybucja web to app — co gubi się na granicy strony i aplikacji
AppsFlyer przypisuje instalację do kampanii na podstawie linku, w który kliknął użytkownik. Baner na stronie zwykle prowadzi prosto do App Store albo Google Play, więc link nie niesie żadnej informacji o kampanii. Rozwiązaniem jest OneLink — link AppsFlyera, który przekierowuje do właściwego sklepu i przy okazji przenosi parametry: pid (media source), c (kampania), af_sub1–5 i inne.
Problem przesuwa się więc o krok: skąd wziąć parametry kampanii w chwili, gdy użytkownik klika baner? W URL-u strony, na której jest baner, zwykle już ich nie ma. Użytkownik przeszedł trzy podstrony albo wrócił po kilku dniach.
AppsFlyer Smart Script V2 — co pamięta, a co gubi
Oficjalny AppsFlyer Smart Script czyta parametry z URL-a wejścia (UTM i inne, które wskażesz w konfiguracji) i buduje z nich OneLink funkcją generateOneLinkURL. Od wersji 2.5.0 dokumentacja zaleca ładowanie skryptu na każdej stronie, bo wtedy parametry z wejścia „przechodzą” na kolejne podstrony.
Dokumentacja nie mówi, jak długo. Kod biblioteki (sprawdzałem build 2.10.4) mówi: parametry wejścia trafiają do localStorage z datą wygaśnięcia dwie godziny od wejścia. Po tym czasie skrypt je usuwa przy następnej odsłonie.
Z tego wynikają trzy scenariusze:
- Reklama → strona A → strona C → baner w ciągu kwadransa, skrypt na każdej stronie. Oficjalny skrypt sobie poradzi.
- To samo, ale skrypt wpięty tylko na stronach z banerem. Na stronie A nikt nie zapisał parametrów, więc na C ich nie ma — atrybucja przepada.
- Reklama w poniedziałek, powrót w czwartek, baner. Parametry wygasły trzy dni temu — atrybucja przepada.
Trzeci scenariusz to typowa ścieżka zakupowa w modzie, elektronice czy wyposażeniu domu: kilka wizyt w ciągu tygodnia, decyzja przy którejś kolejnej. Dwie godziny pokrywają jedną wizytę, nie ścieżkę.
Jest też drugi, mniej widoczny szczegół. Automatyczne przekazywanie identyfikatorów kliknięcia (gclid, fbclid, gbraid, wbraid) do linku, które odpowiada za postbacki do sieci reklamowych, czyta w kodzie tylko bieżący URL — nie zapamiętane parametry. Na stronie C gclid z wejścia już nie dotrze do OneLinka tą drogą. Opcja googleClickIdKey przeniesie zapamiętany gclid do wybranego af_sub, ale tylko w oknie dwóch godzin i tylko do raw data — nie jako parametr do postbacków.
Sprawdziłem to symulatorem: dwanaście ścieżek przepuszczonych przez tag, każda w trzech modelach atrybucji, z wynikiem w kolumnach jak w raw data AppsFlyera. Najprostsza z nich: wejście z reklamy Google Ads z samym gclid, bez UTM, potem kategoria, karta produktu i baner. Na stronie z banerem nie ma już żadnego parametru kampanii.
| Ścieżka | Mój tag | Smart Script V2 |
|---|---|---|
Google Ads (gclid) → kategoria → produkt | google / cpc | gclid w pamięci, źródło puste |
| Newsletter → następnego dnia direct | newsletter albo direct, zależnie od modelu | parametry wygasły |
Oficjalny skrypt w pierwszej ścieżce ma gclid w pamięci, ale media source mapuje z kluczy URL, zwykle z utm_source, a tego nie było. Bez UTM nie wywnioskuje źródła z identyfikatora kliknięcia. Mój tag rozpoznaje gclid przy wejściu i zapisuje google / cpc, więc link z karty produktu niesie kampanię. Symulacja kończy się na linku wychodzącym ze strony — o tym, komu przypisać instalację, decyduje dalej AppsFlyer.
Jak działa mój tag — trzy klocki w GTM
Tag to Custom HTML w GTM, w ES5, bo trafia do kontenera bez buildu. Logika siedzi w trzech obiektach:
- parser — przy wejściu z zewnątrz odczytuje źródło: UTM, identyfikator kliknięcia, referrer, GA client id z cookie
_ga; - saver — decyduje według modelu atrybucji, czy nowe źródło nadpisuje zapamiętane, i zapisuje wynik w cookie first-party na 30 dni;
- generator — na telefonie przepisuje linki do App Store, Google Play i OneLinka na OneLink z parametrami kampanii.
Idea persystencji mieści się w kilku linijkach (pseudokod, nie kod tagu):
// każde wejście z zewnątrz
var current = parser.getData(url, referrer);
var saved = read(STORE);
write(STORE, pick(saved, current, model), 30);
// klik w baner na telefonie
link.href = toOneLink(link.href, read(STORE));
Ważne jest, czego tag nie zapisuje. Nawigacja wewnątrz sklepu nie jest nowym źródłem. Ruch bez oznaczeń (referrer, direct) tag odczytuje raz na sesję przeglądarki — inaczej każde odświeżenie strony nadpisywałoby kampanię. Ruch z UTM i click id czyta zawsze.
Dwa przypadki pomijam świadomie. Na desktopie tag w ogóle się nie uruchamia — ani nie zapisuje źródła, ani nie przepisuje linków — bo OneLink z kampanią i tak nie zainstaluje aplikacji na laptopie. Ruchu, którego źródło zawiera „facebook”, nie nadpisuje — Meta jest SRN (self-reporting network) i przypisuje instalacje sama, a mój link wprowadziłby drugie, konkurencyjne źródło.
Model atrybucji zamiast „ostatniego URL-a”
Pamięć to połowa sprawy. Druga połowa to decyzja, które wejście wygrywa, gdy użytkownik przychodzi kilka razy z różnych źródeł. Oficjalny skrypt bierze najnowsze zapamiętane wejście z parametrami. Mój tag ma trzy modele:
| Model | Kiedy nowe źródło nadpisuje zapamiętane |
|---|---|
| last non-direct | zawsze, poza wejściem direct (jak w Universal Analytics) |
| last click | zawsze |
| last paid | gdy nowe jest płatne albo zapamiętane było bezpłatne |
Last paid jest bliski logice Campaign Managera i dobrze pasuje do rozliczeń z sieciami reklamowymi: wejście z wyszukiwarki organicznej po kliknięciu reklamy nie odbiera kampanii instalacji. Jedna pułapka: „bezpłatne” to u mnie direct, organic i referral. Wszystko inne jest płatne — także email. Przy last paid newsletter wygrywa z wejściem z Google organic i trzeba to wiedzieć, zanim porówna się dane z raportem sieci.
Model zmienia też wynik scenariusza z początku tekstu. Powrót przez wpisany adres to direct: last non-direct i last paid zostawiają kampanię z poniedziałku, last click ją kasuje. Gdyby klientka wróciła przez Google, kampanię utrzymałby tylko last paid. Dlatego model trzeba wybrać przed wdrożeniem, nie po pierwszym raporcie.
W symulatorze widać to na ścieżce z newslettera: wejście z UTM, a następnego dnia nowa sesja direct i klik w baner.
| Model | Instalacja trafia do |
|---|---|
| last non-direct | newsletter |
| last click | direct |
| last paid | newsletter |
Ta sama ścieżka, te same kliknięcia, dwa różne kanały w raporcie instalacji.
Bez UTM tag upraszcza logikę Universal Analytics: gclid to google / cpc, referrer z Google to google / organic, inna domena to {host} / referral, brak referrera — direct. Inne wyszukiwarki niż Google trafiają do referral. Oficjalny skrypt od wersji 2.10.3 ma opcję SEO, która rozpoznaje wyszukiwarki i asystentów AI po referrerze. Ogólnego referralu (blog, partner, porównywarka) jednak nie nazywa — przekazuje najwyżej surowy document.referrer w wybranym parametrze.
GA client id w AppsFlyer — zszycie ścieżki w BigQuery
Tag dokleja do OneLinka GA client id z cookie _ga w jednym z parametrów af_sub. AppsFlyer zapisuje go przy instalacji, więc w eksporcie danych do BigQuery instalacja ma klucz, po którym da się ją połączyć z sesjami webowymi z eksportu GA4. Widać wtedy pełną ścieżkę: reklama, wizyty na stronie, klik w baner, instalacja, zakup w aplikacji. Ten sam problem — tożsamość, która ginie między systemami — rozwiązywałem przy przywracaniu fbc i fbp w Meta CAPI.
Linki przepisane przez tag mają też własny kanał (af_channel), więc wynik sprawdzam w BigQuery wprost: ile instalacji przyszło z linków tagu i jaka ich część niesie GA client id. Ta druga liczba mówi, ile ścieżek w ogóle da się zszyć:
SELECT
af_channel,
COUNT(*) AS installs,
SAFE_DIVIDE(
COUNTIF(NULLIF(af_sub4, '') IS NOT NULL),
COUNT(*)
) AS share_with_ga_cid
-- installs: instalacje z eksportu AppsFlyera
FROM installs
GROUP BY af_channel;
Jeden niuans przy czytaniu tej liczby: client id trafia do pamięci tagu w chwili wejścia. Jeśli cookie _ga powstało później (zgoda kliknięta na drugiej stronie), link dostanie client id dopiero przy następnym wejściu z zewnątrz.
Oficjalny skrypt też przyjmie client id — jako wartość domyślną parametru af_sub, tylko trzeba go samemu odczytać z _ga. Zszycie w BigQuery zadziała wtedy niezależnie od dwóch godzin, bo kampanię odczytasz z sesji GA4. Ale sam AppsFlyer i postbacki do sieci reklamowych dalej zobaczą tylko to, co skrypt zapamiętał.
Pułapki z wdrożenia i rekonstrukcji
Safari skraca pamięć. ITP ogranicza cookie ustawiane z JavaScriptu do 7 dni, a po wejściu z linku z parametrami z domeny uznanej za śledzącą — do 24 godzin. A pierwszy zapis pada akurat na stronie wejścia z reklamy, gdzie limit 24 godzin trafia najczęściej. Na iPhonie, czyli tam, gdzie baner do App Store ma największe znaczenie, realny horyzont to więc od doby do tygodnia, nie 30 dni. Wciąż dłużej niż dwie godziny, ale przy porównaniach trzeba to uczciwie uwzględnić. Ten sam mechanizm opisywałem przy deduplikacji prowizji afiliacyjnych w GTM — tam też pełne okno daje dopiero cookie z serwera.
Baner renderuje się po DOM Ready. Część banerów (smart bannery, pop-upy z CMS-a) pojawia się kilka sekund po załadowaniu strony. Tag odpalony na DOM Ready nie znajdzie linku, który jeszcze nie istnieje. Dlatego obok tagu działa drugi, mały nasłuchiwacz: gdy baner pojawi się w DOM, wypycha zdarzenie do dataLayer i tag przepisuje linki jeszcze raz.
SPA i referrer, który się nie zmienia. W sklepie na SPA document.referrer zostaje zewnętrzny także po zmianie adresu przez pushState. Tag odpalony na wirtualnej odsłonie widzi więc referrer z Google i może uznać ją za nowe wejście z wyszukiwarki — kampania z reklamy przegrywa z „Google organic”, którego nie było. Wirtualne odsłony trzeba odfiltrować osobno, zanim tag dostanie trigger na zdarzenie odsłony w SPA. Testy pojedynczych funkcji tego nie złapią — potrzebna jest symulacja całej ścieżki: wejście z reklamy, potem wirtualna odsłona z tym samym referrerem.
Zgoda. Tag zapisuje cookie i czyta _ga, więc odpala się tylko przy ad_storage i analytics_storage. Oficjalny skrypt też zapisuje dane w localStorage, i to już przy załadowaniu, nie przy budowie linku. Bramkować zgodą trzeba więc samo ładowanie skryptu — łatwo o tym zapomnieć, bo to „skrypt dostawcy”.
Funkcja z efektem ubocznym. Przy odtwarzaniu kodu znalazłem błąd z pierwszej wersji: funkcja sprawdzająca, czy wejście jest z zewnątrz, przy okazji oznaczała sesję jako odczytaną. W trybie testowym wołana była dwa razy, więc dla ruchu bez oznaczeń drugi wynik zawsze brzmiał „nie” i debugowanie pokazywało coś innego niż produkcja. Takie rzeczy łapią testy jednostkowe — tag uruchamiam w Node na atrapie przeglądarki (cookie z domenami, localStorage, <a> jako parser URL-i), bez Chrome’a i bez podglądu GTM. Testy sprawdziłem mutacyjnie: przywrócenie innego błędu z pierwszej wersji, wywołania nieistniejącej funkcji parsera, wywraca 28 testów.
Ekosystem się zmienił. Google wyłączył Firebase Dynamic Links 25 sierpnia 2025 r., więc pierwotna ścieżka przez Dynamic Links jest dziś martwa. Google Ads na iOS coraz częściej przekazuje gbraid albo wbraid zamiast gclid — oficjalny skrypt je przekazuje, mój tag jeszcze nie. Takie wejście bez UTM wygląda u niego jak Google organic. To pierwsza pozycja na liście poprawek.
Mój tag a Smart Script V2 — uczciwe zestawienie
| Mój tag | Smart Script V2 (2.10.4) | |
|---|---|---|
| Pamięć źródła | cookie, 30 dni (Safari: 1–7) | URL: 2 h; referrer: do kolejnego wejścia |
| Reklama → direct po 3 dniach → baner | kampania (last non-direct, last paid) | parametry wygasły |
| Który kontakt wygrywa | last non-direct, last click, last paid | najnowsze wejście z parametrami |
| Ruch bez UTM | Google organic, referral z hostem, direct | wyszukiwarki i AI, reszta jako surowy referrer |
| Click id | gclid, dclid z pamięci, tylko raw data | 7 typów z bieżącego URL-a; gclid 2 h w raw data |
| GA client id w OneLinku | tak | ręcznie, przez parametry własne |
| QR na desktopie, impressions | nie | tak |
| Utrzymanie | własny kod i testy | biblioteka AppsFlyera |
Co bym wdrożył dziś
Nie bronię własnego generatora linków. AppsFlyer rozwija Smart Script — QR na desktop, impressions, nowe identyfikatory kliknięć, rozpoznawanie asystentów AI — i nie ma sensu tego powielać. Oficjalny skrypt ma dwie słabości: krótką pamięć i brak modelu atrybucji.
Dlatego dziś wdrożyłbym wariant hybrydowy. Smart Script V2 buduje link. Warstwa persystencji z mojego tagu (parser + saver) dostarcza mu źródło: zapamiętane na tygodnie, wybrane według modelu, z GA client id. Te wartości trzeba podać skryptowi jako domyślne, bez kluczy URL — inaczej parametry zapamiętane przez niego w ostatnich dwóch godzinach wygrają z modelem. Własny generator zostaje tylko tam, gdzie oficjalnego skryptu nie da się użyć.
- Sprawdź, czy Smart Script jest na każdej stronie. Jeśli tylko na tych z banerem, gubisz już ścieżkę reklama → strona A → strona C.
- Dwie godziny to jedna wizyta, nie ścieżka zakupowa. Przy kilkudniowych decyzjach potrzebna jest własna pamięć źródła.
- Model atrybucji ustal przed wdrożeniem. Last paid i last non-direct dają różne liczby dla tych samych instalacji.
- GA client id w OneLinku kosztuje jedną linijkę, a bez niego ścieżki web i app nie połączysz w BigQuery.
Jeśli masz aplikację, baner na stronie i podejrzanie dużo instalacji „organic” — to dobry punkt startowy do audytu atrybucji web → app.