Null ist keine Messung
Ich habe Attribution ausgerollt, Tests dafür geschrieben, und zehn Tage später waren alle Zeilen leer. Ich las das als „noch zu früh". Tatsächlich bedeutete es, dass der Code kein einziges Mal gelaufen war — und der zweite Bug darin war schlimmer, weil er statt einer Leerstelle eine Antwort geliefert hätte.
Am 8. August habe ich meinem Produkt Attribution hinzugefügt: fünf Spalten im Profil, die festhalten, woher jeder neue Nutzer kommt. Quelle, Medium, Kampagne, Referrer, Land. Geschrieben bei der Registrierung, nur beim ersten Kontakt, danach nie wieder überschrieben.
Es gab Tests dafür. Es hat das Review bestanden. Es ist live gegangen.
Zehn Tage später habe ich zum ersten Mal die naheliegende Abfrage laufen lassen:
select coalesce(signup_country, '?') as country,
coalesce(signup_source, 'direct') as source,
count(*) as signups
from profiles
group by 1, 2;
Eine einzige Zeile kam zurück. ?, direct, 12.
Mein erster Gedanke war, dass zwölf Nutzer nicht viel sind und die Daten sich schon noch füllen würden. Dieser Gedanke war falsch, und zwar auf eine ganz bestimmte Art, die ich beschreiben möchte, weil ich sie für weit verbreitet halte.
Null Zeilen und „noch nicht" sehen identisch aus
Ein falscher Wert meldet sich von selbst. Hätte in der Spalte US gestanden für jemanden aus Kyjiw, wäre ich innerhalb einer Minute auf Fehlersuche gegangen.
Eine leere Spalte sagt gar nichts, und der Kopf liefert von sich aus die freundlichste verfügbare Erklärung: das Feature ist neu, der Traffic ist klein, gib ihm Zeit. Diese Erklärung steht immer zur Verfügung, sie stimmt oft, und sie lässt sich nicht widerlegen, ohne tatsächlich nachzusehen. Also unterbleibt die Prüfung.
Nachgesehen habe ich erst, weil mir auffiel, dass sich einige dieser zwölf nach dem Release des Features registriert hatten. Also habe ich die Frage aufgeteilt:
select coalesce(u.raw_app_meta_data->>'provider', 'email') as provider,
count(*) as users,
count(*) filter (where p.created_at >= '2026-08-08') as after_it_shipped,
count(*) filter (where p.signup_country is not null) as with_country
from auth.users u
join profiles p on p.id = u.id
group by 1;
| provider | users | after_it_shipped | with_country |
|---|---|---|---|
| 9 | 6 | 0 | |
| 3 | 0 | 0 |
Sechs Nutzer sind durch den Code gelaufen, nachdem es ihn schon gab, und haben nichts hinterlassen. Das ist kein Problem der Stichprobengröße. Das ist Code, der nicht ausgeführt wird.
Wo er nicht lief
Die Attribution war in der API-Route für die Registrierung untergebracht. E-Mail-Registrierungen senden dorthin ein POST. OAuth tut das nicht — es spricht direkt mit dem Auth-Provider und kehrt auf eine Callback-Seite zurück, die von alldem nichts weiß.
Drei Viertel meiner Registrierungen laufen über Google.
Der Kommentar, der das erklärt, stand schon in der Callback-Datei — von mir selbst geschrieben, Wochen zuvor:
OAuth sign-ups skip /api/auth/signup, so the Telegram alert is sent from here.
Von der Weiche wusste ich. Für die Benachrichtigung hatte ich sie berücksichtigt. Als ich später die Attribution hinzufügte, habe ich sie in die Route gepackt — dorthin, wo Registrierung „offensichtlich" passiert — und an die Weiche nicht mehr gedacht, weil mich nichts dazu gezwungen hat. Die Tests, die ich geschrieben habe, riefen die Route auf. Natürlich taten sie das; das war der Code, den ich geschrieben hatte.
Eine Testsuite deckt die Pfade ab, an die du gedacht hast — genau wie deine Erinnerung an die Architektur. Die Weiche war vier Dateien weiter dokumentiert, in einem Kommentar, den ich selbst geschrieben hatte, und das half nichts.
Der zweite Bug, der schlimmer war
Beim Fixen des OAuth-Pfads habe ich nachgesehen, wo die Erfassung im Browser überhaupt stattfindet. captureAttribution() — die Funktion, die den Referrer bei der Ankunft speichert — lief auf genau zwei Seiten: der Landingpage und dem Registrierungsformular.
Dort kommen die Leute aber nicht an.
Meine gesamte Content-Strategie lenkt Suchtraffic auf rollenspezifische Fragenseiten und Blogartikel. Wer über Google einen Artikel findet, landet dort, liest ihn und klickt sich weiter. captureAttribution() lief nie, weil diese Seiten Server-Components sind, in denen es keine Stelle gibt, sie aufzurufen. Bis der Besucher das Registrierungsformular erreichte, war der einzig verfügbare Referrer unsere eigene Domain — und die Funktion verwirft interne Referrer, zu Recht, weil sie keine Quellen sind.
Also wurde der Besucher als direct erfasst.
Das ist schlimmer als null, und es lohnt sich, genau zu sagen, warum. Null bedeutet fehlende Daten, und fehlende Daten werden meistens ignoriert. direct ist eine Antwort. Sie sagt: Diese Person hat eure Domain selbst eingetippt, niemand hat sie geschickt. Häufen sich genug davon, sagt der Report, dass Suche euch niemanden bringt — das genaue Gegenteil der Wahrheit, und genau die Art Schlussfolgerung, nach der gehandelt wird. Eine Zahl, die deine Frage beantwortet, prüft man nicht nach.
Beide Bugs erzeugten Schweigen. Einer davon erzeugte eine Lüge.
Die Fixes
Der OAuth-Pfad sendet jetzt vom Callback aus ein POST an eine kleine Route. Ohne vor dem Redirect darauf zu warten — zu wissen, woher jemand kommt, ist nichts wert im Vergleich dazu, ihn überhaupt hereinzulassen.
Die Bedingung steckt im Schreibvorgang selbst, weil der Callback bei jedem Login läuft, nicht nur beim ersten:
await supabaseAdmin
.from('profiles')
.update(values)
.eq('id', user.id)
.is('signup_source', null) // erster Kontakt gewinnt
.is('signup_country', null)
.select('id')
Würde man die Spalte erst lesen und dann schreiben, könnten zwei Callbacks beide null sehen und beide den Anspruch erheben. Die gleiche Form wie bei einem Payment-Webhook-Race, über das ich früher schon geschrieben habe — Check-then-Act bleibt Check-then-Act, egal wo es auftaucht.
Die Erfassung ist ins Root-Layout gewandert, damit sie läuft, egal wo der Besucher landet — auch auf Seiten, die erst nächstes Jahr geschrieben werden.
Und die Migration ist umgezogen. Die fünf Spalten lagen in docs/migrations/, was niemand erneut abspielt — von Hand auf die Produktion angewendet und auf einer frischen Datenbank nicht vorhanden, wo der Insert bei der Registrierung schlicht fehlgeschlagen wäre. Ich habe einen Test, der jede im Quellcode referenzierte Tabelle liest und für sie eine Migration verlangt. Er prüft Tabellen. Über Spalten sagt er nichts.
Was ich daraus mitnehme
Das Fehlen von Daten ist kein Beweis für irgendetwas. Es passt genauso gut zu „kein Traffic" wie zu „kein Code", und die beiden verlangen entgegengesetzte Reaktionen. Der einzige Weg, sie auseinanderzuhalten, ist eine Abfrage, die in beiden Fällen unterschiedlich aussehen würde — hier die Aufschlüsselung nach Registrierungs-Provider und Datum, die dreißig Sekunden gedauert hat und zehn Tage zu spät kam.
Wenn ein Feature über sich selbst berichtet, braucht es früh einen ersten Blick. Kein Test — ein Blick in die echte Tabelle, am Tag des Release, mit der Frage „steht hier überhaupt irgendetwas drin". Beide Bugs hätte ich am 9. August gefunden.
Eine Zahl, die deine Frage beantwortet, verdient mehr Misstrauen als eine, die es nicht tut. direct passte zu einer Geschichte, die ich glauben konnte. Null tat das, auf seine Art, auch. Die Prüfung, die ich tatsächlich durchgeführt habe, wurde durch eine kleine Ungereimtheit ausgelöst — manche dieser Nutzer sind neuer als das Feature selbst —, und so läuft es meistens.
Nichts davon ist exotisch. So versagt Instrumentierung normalerweise: leise, und in die Richtung, in der alles gut aussieht.