LW IT Solutions logo LW IT Solutions logo LW IT Solutions
« Przegląd bloga /Digital Marketing/Tutorials / Monitorowanie koszyka i kasy przez GA4: wykrywanie...
Ten artykuł w innych językach:

Monitorowanie koszyka i kasy przez GA4: wykrywanie nagłych spadków liczby zdarzeń

Monitorowanie koszyka i kasy przez GA4: wykrywanie nagłych spadków liczby zdarzeńMonitorowanie koszyka i kasy przez GA4: wykrywanie nagłych spadków liczby zdarzeń

Management Summary

Monitor sklepu obserwuje, jak często produkty trafiają do koszyka i rozpoczyna się zamówienie. Porównując aktualne wyniki GA4 z odpowiednim okresem poprzedniego tygodnia, może wskazać nietypowy spadek podczas kampanii reklamowej. Daje to wcześniejszy sygnał do wyjaśnienia sytuacji, zanim pojawi się duży ubytek sprzedaży. Liczby opisują zarejestrowane działania, a nie ich przyczynę. Mniejszy popyt, zmiana zgód lub problem pomiaru także mogą wywołać spadek, bez awarii procesu zakupu.

Spis treści
  1. 1. Źródło danych i konfiguracja
  2. 2. Zapisywanie i porównywanie odczytów
  3. 3. Status i widoczna aktualność
  4. 4. Opóźnienie i interpretacja
  5. Pytania i odpowiedzi
  6. Źródła

Nagły spadek add_to_cart lub begin_checkout może wskazywać błąd pomiaru, problem z realizacją zakupu albo mniejszy ruch. Niewielki monitor oparty na GA4 Realtime API pomaga zauważyć zmianę podczas trwającej kampanii. Pomiar obejmuje zgłoszone zdarzenia, a nie działające koszyki, zakończone zamówienia ani przychód.

Przykład zapisuje w Arkuszach Google liczbę zdarzeń z poprzednich 30 minut, mniej więcej co pięć minut. Każdy nowy odczyt przesuwa okno pomiaru. Porównanie wykorzystuje pasujący zapis sprzed tygodnia. Brak historii, błędy API i nieaktualne wyniki otrzymują osobne stany. Niedostępne dane nie udają dzięki temu prawidłowego ruchu ani awarii z zerową liczbą zdarzeń.

Bieżące zapisy liczby zdarzeń, odpowiadające okno sprzed tygodnia i oddzielne stany monitorowania
Odczyt co pięć minut nie oznacza pięciominutowego okna pomiaru ani gwarantowanego czasu wykrycia.

1. Źródło danych i konfiguracja

Realtime API obsługuje eventName i eventCount. Standardowe usługi udostępniają do 30 minut historii, a Analytics 360 do 60 minut. Przykład jawnie pobiera minuty od 29 do 0 w obu wariantach. BigQuery również oferuje eksport strumieniowy z danymi dostępnymi w ciągu kilku minut. Streaming jest inną możliwością, z kosztami i możliwymi lukami. W tym tutorialu decyduje prostota, a nie rzekomy brak streamingu.

Wymagane są osobny arkusz, powiązany projekt Apps Script, prawa odczytu GA4 dla konta wykonującego skrypt oraz włączona zaawansowana usługa Analytics Data. Powiązany projekt Cloud musi również mieć włączone Google Analytics Data API. Stałe wymagają właściwej nazwy zasobu usługi GA4, identyfikatora arkusza i strefy czasowej usługi.

Ręczne, autoryzowane uruchomienie pollRealtime tworzy dwie osobne karty: RT_Log dla zapisów i RT_Status dla bieżącego wyniku. Istniejące karty o tych nazwach muszą być przeznaczone wyłącznie dla tego przykładu; niezgodny nagłówek zatrzymuje wykonanie. Po sprawdzeniu konfiguracji instalowalny wyzwalacz czasowy może uruchamiać pollRealtime co pięć minut. Wykonanie korzysta z uprawnień konta tworzącego wyzwalacz i podlega limitom oraz opóźnieniom harmonogramu. Wystarczy jeden wyzwalacz.

2. Zapisywanie i porównywanie odczytów

JavaScript
// Dedicated spreadsheet; enable Analytics Data advanced service first.
const RT = {
  property: 'properties/XXXXXXXXX',
  spreadsheet: 'SPREADSHEET_ID',
  timezone: 'Europe/Warsaw',
  events: ['add_to_cart', 'begin_checkout'],
  window: 'rt_29_to_0_v1',
  minBaseline: 20,
  toleranceMs: 2 * 60 * 1000
};

function readCounts(response) {
  if (!response || !Array.isArray(response.dimensionHeaders) ||
      response.dimensionHeaders.length !== 1 ||
      response.dimensionHeaders[0].name !== 'eventName' ||
      !Array.isArray(response.metricHeaders) ||
      response.metricHeaders.length !== 1 ||
      response.metricHeaders[0].name !== 'eventCount') {
    throw new Error('Unexpected response schema');
  }
  const rows = response.rows || [];
  if (!Array.isArray(rows) || (response.rowCount || 0) !== rows.length) {
    throw new Error('Incomplete response');
  }
  const counts = {add_to_cart: 0, begin_checkout: 0};
  const seen = new Set();
  rows.forEach(row => {
    const name = row.dimensionValues && row.dimensionValues[0] && row.dimensionValues[0].value;
    const raw = row.metricValues && row.metricValues[0] && row.metricValues[0].value;
    if (!RT.events.includes(name) || seen.has(name) ||
        typeof raw !== 'string' || !/^\d+$/.test(raw)) throw new Error('Invalid row');
    const count = Number(raw);
    if (!Number.isSafeInteger(count)) throw new Error('Invalid count');
    counts[name] = count;
    seen.add(name);
  });
  return counts;
}

function compareCount(current, baseline) {
  if (baseline === null) return ['', 'NO_BASELINE'];
  if (baseline < RT.minBaseline) return ['', 'LOW_VOLUME'];
  const change = 100 * (current - baseline) / baseline;
  return [change, change <= -30 ? 'DROP' : change <= -15 ? 'WATCH' : 'OK'];
}

function baselineFor(history, time, event) {
  const target = time.getTime() - 7 * 24 * 60 * 60 * 1000;
  let best = null, distance = Infinity;
  history.forEach(row => {
    const timestamp = row[0] instanceof Date ? row[0].getTime() : NaN;
    const delta = Math.abs(timestamp - target);
    if (row[1] === event && row[3] === 'OK' && row[4] === RT.window &&
        row[5] === RT.property && Number.isSafeInteger(row[2]) && row[2] >= 0 &&
        delta <= RT.toleranceMs && delta < distance) {
      best = row[2]; distance = delta;
    }
  });
  return best;
}

function preparedSheet(book, name, header) {
  const sheet = book.getSheetByName(name) || book.insertSheet(name);
  if (sheet.getLastRow() === 0) sheet.getRange(1, 1, 1, header.length).setValues([header]);
  const actual = sheet.getRange(1, 1, 1, header.length).getValues()[0];
  if (JSON.stringify(actual) !== JSON.stringify(header)) throw new Error('Unexpected sheet header');
  return sheet;
}

function pollRealtime() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(1000)) return;
  try {
    const book = SpreadsheetApp.openById(RT.spreadsheet);
    const log = preparedSheet(book, 'RT_Log', ['poll_time', 'event', 'count', 'fetch', 'window', 'property']);
    const dash = preparedSheet(book, 'RT_Status', ['poll_time', 'event', 'current', 'baseline', 'change_pct', 'status', 'freshness']);
    const history = log.getLastRow() > 1 ? log.getRange(2, 1, log.getLastRow() - 1, 6).getValues() : [];
    const time = new Date();
    let counts = null, fetch = 'OK';
    try {
      const request = {
        dimensions: [{name: 'eventName'}],
        metrics: [{name: 'eventCount'}],
        minuteRanges: [{startMinutesAgo: 29, endMinutesAgo: 0}],
        dimensionFilter: {filter: {fieldName: 'eventName', inListFilter: {values: RT.events, caseSensitive: true}}},
        limit: '2'
      };
      counts = readCounts(AnalyticsData.Properties.runRealtimeReport(request, RT.property));
      if (Date.now() - time.getTime() > 60000) throw new Error('Slow response');
    } catch (error) {
      counts = null; fetch = 'FETCH_ERROR';
    }
    const target = new Date(time.getTime() - 7 * 24 * 60 * 60 * 1000);
    const offsetChanged = Utilities.formatDate(time, RT.timezone, 'Z') !== Utilities.formatDate(target, RT.timezone, 'Z');
    const statusRows = RT.events.map((event, index) => {
      const baseline = baselineFor(history, time, event);
      const result = fetch !== 'OK' ? ['', 'FETCH_ERROR'] : offsetChanged ? ['', 'DST_REVIEW'] : compareCount(counts[event], baseline);
      return [time, event, counts ? counts[event] : '', baseline === null ? '' : baseline,
        result[0], result[1], `=IF((NOW()-A${index + 2})*1440>10,"STALE","FRESH")`];
    });
    const logRows = RT.events.map(event => [time, event, counts ? counts[event] : '', fetch, RT.window, RT.property]);
    log.getRange(log.getLastRow() + 1, 1, logRows.length, 6).setValues(logRows);
    dash.getRange(2, 1, statusRows.length, 7).setValues(statusRows);
    dash.getRange(2, 5, 2, 1).setNumberFormat('0.0');
    dash.getRange(2, 1, 2, 1).setNumberFormat('yyyy-mm-dd hh:mm:ss');
    // Trim only the chronological prefix of this dedicated log, preserving its header.
    const cutoff = time.getTime() - 14 * 24 * 60 * 60 * 1000;
    let oldRows = 0;
    while (oldRows < history.length && history[oldRows][0] instanceof Date &&
           history[oldRows][0].getTime() < cutoff) oldRows++;
    if (oldRows) log.deleteRows(2, oldRows);
  } finally {
    lock.releaseLock();
  }
}

Dziennik przechowuje czas odczytu, nazwę zdarzenia, licznik, stan pobrania, wersję okna i usługę GA4. Czternaście dni chronologicznej historii zapewnia dane z poprzedniego tygodnia. Dziennik jest obsługiwany wyłącznie przez ten skrypt. Ręczne sortowanie lub mieszane importy historyczne zmieniałyby kolejność danych. Czyszczenie zakłada jednak, że najstarsze wpisy znajdują się na początku. Zmiana definicji zapytania wymaga nowej wersji window i nowej porównywalnej historii.

Punktem odniesienia jest najbliższy poprawny zapis względem chwili sprzed 168 godzin, z tolerancją dwóch minut. Brak pasującego zapisu daje NO_BASELINE. Zerowa wartość odniesienia lub wartość poniżej 20 zdarzeń daje LOW_VOLUME. Ten konfigurowalny próg ogranicza przypadkowe wahania, ale nie jest statystycznym testem ufności. Zmiana przesunięcia strefy czasowej między datą bieżącą a datą odniesienia daje DST_REVIEW: ta sama pora tygodnia w UTC po zmianie czasu nie oznacza tej samej lokalnej godziny.

Poprawna odpowiedź bez wiersza dla danego zdarzenia daje dla tego zdarzenia zero. Nieudane żądanie, nieprawidłowa odpowiedź lub pobieranie trwające ponad minutę daje puste liczniki i FETCH_ERROR. Błąd dostępu do arkusza lub zapisu nadal może zatrzymać skrypt. Wskaźnik aktualności i historia wykonań Apps Script są zatem koniecznymi elementami kontroli. Blokada skryptu zapobiega równoległym wykonaniom w obrębie projektu. Oddzielne zapisy nadal mogą zakończyć się powodzeniem lub błędem niezależnie od siebie. Na przykład dziennik może zostać zaktualizowany, a karta statusu pozostać bez zmian.

3. Status i widoczna aktualność

Status wykorzystuje niezaokrągloną zmianę: 100 × (bieżąca wartość − wartość odniesienia) / wartość odniesienia. Spadek o co najmniej 30% daje DROP. Spadek od 15% do mniej niż 30% daje WATCH. Pozostałe oceniane wartości otrzymują OK. Są to etykiety prostego porównania z jednym tygodniem odniesienia. Promocja, święto lub nietypowy poprzedni tydzień mogą podważyć przydatność tej podstawy.

Kolumna G zawiera formułę aktualności. Ustawienia obliczeń arkusza muszą przewidywać przeliczanie po zmianie i co minutę, aby NOW() aktualizowało czas również po zatrzymaniu odczytów. Wynik starszy niż dziesięć minut otrzymuje STALE; ten próg także jest decyzją operacyjną. Znacznik czasu pozostaje widoczny, ponieważ formuła arkusza nie zastępuje niezależnego monitorowania dostępności.

Formatowanie warunkowe obejmuje A2:G3 w RT_Status. Szara reguła =$G2="STALE" ma pierwszeństwo. Czerwona reguła używa =AND($G2="FRESH",$F2="DROP"), a pomarańczowa =AND($G2="FRESH",$F2="WATCH"). Pozostałe stany poza OK wymagają odróżnialnego neutralnego formatowania. Dawne czerwone lub zielone wyniki nie mogą wyglądać na aktualne po zatrzymaniu odczytów. Ustawienia regionalne arkusza mogą wymagać innych separatorów formuł.

FRESH opisuje wyłącznie wiek wykonania monitora. Niedawne poprawne żądanie API nie dowodzi ani dotarcia bieżących zdarzeń do GA4, ani kompletności danych źródłowych. Opóźnienie zbierania może zatem występować mimo aktualnego znacznika czasu w dashboardzie.

4. Opóźnienie i interpretacja

Każda próbka obejmuje około 30 minut, a próbki pobierane co pięć minut mocno się nakładają. Suma tych zapisów nie daje dziennej liczby zdarzeń. Przy idealnie równomiernym ruchu pierwsza próbka pięć minut po całkowitej awarii nadal zawiera około 25 minut prawidłowych danych: spadek wynosi około 17%. Po dziesięciu minutach przykładowy spadek wynosi około 33%. Opóźnienie zbierania, granice minut, czas uruchomienia i nierównomierny ruch wykluczają gwarancję wykrycia po dziesięciu minutach.

Podzielenie godzinowego licznika z przetworzonego raportu przez dwa nie daje zaobserwowanej połowy godziny. Taka operacja zakłada równomierną aktywność w godzinie i miesza różne etapy przetwarzania. Przykład konsekwentnie wykorzystuje zapisane odczyty Realtime i potrzebuje co najmniej tygodnia odpowiedniej historii. Codzienne porównanie z transakcjami sklepu i przetworzonymi danymi analitycznymi uzupełnia kontrolę. Ani 14-dniowy Z-score, ani ruchoma wartość odniesienia nie wykrywają automatycznie każdego powolnego spadku.

Widokiem operacyjnym jest bezpośrednio arkusz. Konektor Arkuszy Google w Looker Studio oferuje 15 minut jako minimalny i domyślny interwał aktualności danych. Pięciominutowy harmonogram Apps Script nie zapewnia w tym konektorze wyświetlania nowych danych co pięć minut. Automatyczne odświeżanie raportu nie omija pamięci podręcznej wynikającej z ustawień aktualności źródła.

Trzy obręcze leżą na trawie i nachodzą na siebie, a ślimak siedzi tam, gdzie wszystkie się pokrywają. Tak samo ta sama osoba liczy się w kilku oknach czasu rzeczywistego, więc ich suma to nie liczba dzienna.
Trzy obręcze leżą na trawie i nachodzą na siebie, a ślimak siedzi tam, gdzie wszystkie się pokrywają. Tak samo ta sama osoba liczy się w kilku oknach czasu rzeczywistego, więc ich suma to nie liczba dzienna.

Pytania i odpowiedzi

Czy czerwony wynik dowodzi awarii realizacji zakupu?

Nie. Błędy tagów, zmiany zgód, mniejszy popyt i błędy checkoutu mogą obniżać liczniki GA4. Zamówienia sklepu, dzienniki błędów, automatyczne testowe przejścia przez proces zakupu i diagnostyka tagów uwzględniająca zgodę dostarczają niezależnych dowodów. Sama obecność zamówień nie wyklucza częściowej awarii.

Czy rzeczywisty spadek może pozostać zielony?

Tak. Mały spadek może nie przekroczyć progu, a już słaby tydzień odniesienia może ukryć długotrwały problem. Brak podstawy i małe wolumeny są wyłączone z oceny, zamiast otrzymywać stan prawidłowy. Osobne kontrole dzienne i kilka porównywalnych tygodni odniesienia mogą poprawić zakres wykrywania.

Czy przykład wysyła powiadomienia?

Nie. Skrypt zapisuje stany w przeznaczonym do tego arkuszu. Wiarygodne powiadomienia wymagają dodatkowej ścieżki dostarczania, ograniczenia powtórzeń i niezależnej kontroli działania samego monitora.

Co wymaga sprawdzenia na rzeczywistym koncie?

Autoryzacja, dostępność zdarzeń, limity API, wyzwalacz, ustawienia regionalne i przeliczanie arkusza, dostępność odniesień oraz widoczne stany błędów. Izolowane testy kodu nie zastępują całościowej kontroli Apps Script i GA4.

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 i formconfirmed do ochrony przed spamem.

Napisanie komentarza

Doświadczenia z praktyki i pytania o wdrożenie są tu mile widziane.

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

LUKASWOJCIK.COM · NEWSLETTER

Nowe pomysły w skrzynce

Nowe artykuły, praktyczne narzędzia i informacje o projektach.

Wybierz tematy (opcjonalnie)
Bez wyboru: wszystkie tematy.

Zapis wymaga potwierdzenia e-mailem. · Prywatność

Prywatność · Newsletter

Adres e-mail, język, wybrane tematy oraz daty zapisu i potwierdzenia są przechowywane w celu wysyłki newslettera. Wymagane jest potwierdzenie zapisu. Każda wiadomość zawiera link do natychmiastowej rezygnacji. Formconfirmed chroni formularz przed spamem. Brak śledzenia otwarć i kliknięć.

Pełna polityka prywatności ↗