Warenkorb und Kasse mit GA4 überwachen: Plötzliche Ereignisrückgänge erkennen

Management Summary
Ein Shop-Monitor beobachtet, wie oft Produkte im Warenkorb landen und Bestellvorgänge beginnen. Aktuelle GA4-Zählwerte werden mit einem passenden Zeitraum der Vorwoche verglichen, um ungewöhnliche Rückgänge während einer Werbekampagne sichtbar zu machen. Das ermöglicht eine frühere Untersuchung, bevor ein größerer Umsatzverlust auffällt. Die Zahlen beschreiben erfasste Einkaufsaktivität und noch keine Ursache. Auch geringere Nachfrage, veränderte Einwilligung oder Messprobleme können den Rückgang statt eines defekten Bestellablaufs erklären.
Inhalt
Ein plötzlicher Rückgang bei add_to_cart oder begin_checkout kann auf fehlerhaftes Tracking, einen gestörten Checkout oder weniger Verkehr hinweisen. Ein kleiner Monitor auf Basis der GA4-Realtime-API hilft, Veränderungen während einer laufenden Kampagne zu erkennen. Gemessen werden gemeldete Ereignisse, keine funktionsfähigen Warenkörbe, abgeschlossenen Bestellungen oder Umsätze.
Das Beispiel speichert ungefähr alle fünf Minuten die Ereigniszahl der jeweils vergangenen 30 Minuten in Google Sheets. Mit jedem Abruf verschiebt sich dieses Messfenster weiter. Als Vergleich dient ein passender Schnappschuss aus der Vorwoche. Fehlende Historie, API-Fehler und veraltete Ergebnisse erhalten eigene Zustände. Nicht verfügbare Daten erscheinen dadurch weder als gesunder Verkehr noch als Ausfall mit null Ereignissen.

1. Datenquelle und Einrichtung
Die Realtime-API unterstützt eventName und eventCount. Standard-Properties bieten bis zu 30 Minuten, Analytics-360-Properties bis zu 60 Minuten Rückblick. Das Beispiel fragt für beide ausdrücklich die Minuten 29 bis 0 ab. Auch BigQuery bietet einen Streaming-Export mit Daten innerhalb weniger Minuten. Streaming ist eine weitere Möglichkeit mit Kosten und möglichen Lücken. Für diese Anleitung entscheidet die Einfachheit, nicht ein vermeintliches Fehlen von Streaming.
Benötigt werden eine eigene Tabelle, ein daran gebundenes Apps-Script-Projekt, GA4-Leserechte des ausführenden Kontos und der aktivierte erweiterte Dienst Analytics Data. Im zugehörigen Cloud-Projekt muss außerdem die Google Analytics Data API aktiviert sein. Die Konstanten erhalten die tatsächliche Property-Ressource, Tabellen-ID und Property-Zeitzone.
Ein manuell autorisierter Lauf von pollRealtime erstellt zwei eigene Blätter: RT_Log für Schnappschüsse und RT_Status für das aktuelle Ergebnis. Bestehende Blätter mit diesen Namen müssen für dieses Beispiel reserviert sein; eine abweichende Kopfzeile beendet die Ausführung. Nach erfolgreicher Einrichtung kann ein installierbarer zeitgesteuerter Trigger pollRealtime alle fünf Minuten aufrufen. Maßgeblich sind die Berechtigungen des erstellenden Kontos. Quoten und Verzögerungen der Zeitsteuerung bleiben möglich. Ein Trigger genügt.
2. Schnappschüsse speichern und vergleichen
// 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();
}
}Das Protokoll enthält Abrufzeit, Ereignisname, Zählwert, Abrufstatus, Fensterversion und Property. Vierzehn Tage chronologische Historie liefern die Vorwochenreferenz. Das Protokoll wird ausschließlich durch dieses Skript gepflegt. Manuelles Sortieren oder vermischte historische Importe würden die Reihenfolge verändern. Die Bereinigung setzt jedoch voraus, dass die ältesten Einträge oben stehen. Eine geänderte Abfragedefinition benötigt eine neue Version in window und frische vergleichbare Historie.
Die Referenz ist der nächste erfolgreiche Schnappschuss zu einem Zeitpunkt 168 Stunden zuvor, mit zwei Minuten Toleranz. Ohne passenden Eintrag bleibt der Status NO_BASELINE. Eine Referenz von null oder unter 20 Ereignissen ergibt LOW_VOLUME. Diese einstellbare Untergrenze dämpft zufällige Ausschläge, ersetzt aber keinen statistischen Konfidenztest. Ein geänderter Zeitzonenoffset zwischen aktuellem Datum und Referenzdatum führt zu DST_REVIEW: Derselbe UTC-Wochenzeitpunkt entspricht nach der Zeitumstellung nicht mehr derselben lokalen Uhrzeit.
Eine gültige Antwort ohne Zeile für ein Ereignis ergibt für dieses Ereignis den Wert 0. Ein fehlgeschlagener Abruf, eine fehlerhafte Antwort oder eine Abrufdauer über einer Minute ergibt dagegen leere Zählwerte und FETCH_ERROR. Fehler beim Tabellenzugriff oder Schreiben können das Skript weiterhin stoppen. Deshalb gehören die Aktualitätsanzeige und die Apps-Script-Ausführungshistorie zur Kontrolle. Eine Skriptsperre verhindert überlappende Läufe innerhalb dieses Projekts. Getrennte Schreibzugriffe können dennoch unabhängig voneinander gelingen oder scheitern. Beispielsweise könnte das Protokoll aktualisiert sein, während das Statusblatt unverändert bleibt.
3. Status und sichtbare Aktualität
Der Status verwendet die ungerundete Änderung: 100 × (aktuell − Referenz) / Referenz. Ein Rückgang ab 30 Prozent ergibt DROP. Ein Rückgang ab 15 bis unter 30 Prozent ergibt WATCH. Andere auswertbare Werte erhalten OK. Die Kennzeichnung beschreibt einen einfachen Vergleich mit einer einzigen Referenzwoche. Eine Aktion, ein Feiertag oder eine ungewöhnliche Vorwoche können diese Referenz unbrauchbar machen.
Spalte G enthält die Aktualitätsformel. In den Tabelleneinstellungen muss die Neuberechnung bei Änderungen und jede Minute aktiviert sein, damit NOW() auch bei ausgefallenen Abrufen fortschreitet. Ein über zehn Minuten altes Ergebnis erhält STALE; auch diese Schwelle ist eine betriebliche Festlegung. Der Zeitstempel bleibt sichtbar, denn eine Tabellenformel ersetzt keine unabhängige Verfügbarkeitsüberwachung.
Die bedingte Formatierung gilt für A2:G3 in RT_Status. Eine graue Regel mit =$G2="STALE" erhält Vorrang. Rot verwendet =AND($G2="FRESH",$F2="DROP"), Gelb-Orange verwendet =AND($G2="FRESH",$F2="WATCH"). Weitere Zustände außer OK brauchen eine unterscheidbare neutrale Darstellung. Alte rote oder grüne Ergebnisse dürfen nach ausgefallenen Abrufen nicht aktuell wirken. Je Tabellengebietsschema sind andere Formeltrennzeichen erforderlich.
FRESH beschreibt ausschließlich das Alter dieses Monitorlaufs. Ein kürzlich erfolgreicher API-Abruf belegt weder den Eingang aktueller Ereignisse in GA4 noch vollständige Quelldaten. Eine vorgelagerte Erfassungsverzögerung kann deshalb trotz aktuellem Dashboard-Zeitstempel bestehen.
4. Verzögerung und Interpretation
Jede Stichprobe umfasst ungefähr 30 Minuten. Im Abstand von fünf Minuten erfasste Stichproben überlappen stark. Die Summe dieser Schnappschüsse ist keine Tagesereigniszahl. Bei idealisiert gleichmäßigem Verkehr enthält die erste Stichprobe fünf Minuten nach einem Totalausfall noch rund 25 Minuten intakter Daten: etwa 17 Prozent Rückgang. Nach zehn Minuten läge der beispielhafte Rückgang bei etwa 33 Prozent. Erfassungsverzögerung, Minutengrenzen, Triggerzeitpunkt und ungleichmäßiger Verkehr verhindern eine garantierte Erkennung nach zehn Minuten.
Eine halbierte Stundenzahl aus einem verarbeiteten Bericht entspricht keiner beobachteten halben Stunde. Die Umrechnung setzt gleichmäßige Aktivität innerhalb der Stunde voraus und mischt unterschiedliche Verarbeitungsstände. Das Beispiel verwendet deshalb durchgehend gespeicherte Realtime-Schnappschüsse und benötigt mindestens eine Woche geeignete Historie. Ein täglicher Abgleich mit Shoptransaktionen und verarbeiteten Analytics-Daten ergänzt die Kontrolle. Weder ein 14-Tage-Z-Score noch eine gleitende Referenz erkennen automatisch jeden langsamen Rückgang.
Die direkte Tabelle dient als operative Ansicht. Der Google-Sheets-Connector von Looker Studio bietet 15 Minuten als kleinstes und voreingestelltes Intervall der Datenaktualität. Ein fünfminütiger Apps-Script-Lauf macht daraus keine garantiert alle fünf Minuten aktualisierte Anzeige. Automatisches Neuladen des Berichts umgeht den zugrunde liegenden Aktualitätscache nicht.

Fragen und Antworten
Belegt ein rotes Ergebnis einen gestörten Checkout?
Nein. Tag-Fehler, geänderter Consent, geringere Nachfrage und Checkoutfehler können die GA4-Zahlen senken. Shopbestellungen, Checkout-Fehlerprotokolle, automatisierte Testdurchläufe des Bestellvorgangs und consentbezogene Tag-Diagnosen liefern unabhängige Hinweise. Vorhandene Bestellungen allein schließen einen teilweisen Checkoutausfall nicht aus.
Kann ein echter Rückgang grün bleiben?
Ja. Kleine Rückgänge überschreiten möglicherweise keine Schwelle. Eine bereits schwache Referenzwoche kann ein länger bestehendes Problem verdecken. Fehlende Referenzen und geringe Volumina bleiben von der Auswertung ausgeschlossen, statt als gesund zu gelten. Separate Tagesprüfungen und mehrere vergleichbare Referenzwochen können die Abdeckung verbessern.
Versendet das Beispiel Benachrichtigungen?
Nein. Das Skript schreibt Zustände in die dafür reservierte Tabelle. Zuverlässige Benachrichtigungen benötigen einen zusätzlichen Versandweg, die Unterdrückung von Wiederholungen und eine unabhängige Prüfung, ob der Monitor selbst noch läuft.
Welche Prüfungen bleiben im tatsächlichen Konto erforderlich?
Berechtigungen, Ereignisverfügbarkeit, API-Quoten, Trigger, Tabellengebietsschema, Neuberechnung, Referenzabdeckung und sichtbare Fehlerzustände. Isolierte Codetests ersetzen keine durchgängige Prüfung mit Apps Script und GA4.


