Ja wo laufen sie denn? (Linux-Magazin, Juli 1999)

Wohin laufen die Benutzer auf einer Website? Cookies helfen, die Gewohnheiten der Fans zu erforschen und ihre Wege durch den HTML-Dschungel nachzuvollziehen.

Die Log-Datei eines Webservers spricht schon Bände. Geeignete Auswertungsprogramme zeigen nicht nur die Benutzerzahlen an, sondern geben, wenn sie die REFERER-Logs mit einbeziehen, auch Antwort auf detailliertere Fragen wie ``Kommt der typische Besucher durch den Haupteingang oder führen hauptsächlich Browser-Bookmarks zu bestimmten Seiten?''

Aber letztendlich kann kein Server-Log der Welt den Benutzern wirklich auf die Finger schauen, denn falls ein Request eingeht, identifiziert der Server nicht die Person, die hinter einem Browser sitzt, sondern meldet die IP-Adresse des anfragenden Rechners -- und die kann im Falle von Online-Riesen wie AOL Hunderttausenden von Benutzern gehören. Fragen wie ``Wieviele Besucher entdeckten die Website letzten Monat das erste Mal?'' ``Wieviele Stammkunden aus dem vorletzten Monat kamen wieder?'' ``Wieviel Zeit verbringt der durchschnittliche Besucher auf jeder Seite auf dem Weg in die Tiefe?'' erfordern eine personalisierte Überwachung. Cookies, die der Server dem jeweiligen Browser heimlich unterjubelt, welcher sie wiederum beim nächsten Ankoppeln unaufgefordert mitsendet, erlauben es dem Server, wiederkehrende Browser zu erkennen. Ein Browser wird zwar nicht in allen Fällen von nur einer Person bedient (z.B. auf Messeständen), doch für den Löwenanteil der Benutzer einer Website gilt der ``Ein Mann/Frau, ein BrowserInnen''-Grundsatz.

Verlangt ein Browser nach der Eingangsseite index.html, sendet der Server kein Cookie, da es sich um eine statische Datei handelt, die er ohne nachzudenken einfach rauspustet. Ein CGI-Skript könnte ein Cookie einschmuggeln, doch es würde wohl einige Telefonanrufe beim Internet-Provider kosten, die Eingangsseite einer Website zu dynamisieren und auf ein CGI-Skript umzuleiten oder einen Server-Filter zu aktivieren.

Es geht auch einfacher: Ein in die Homepage integriertes unscheinbares Ein-Pixel-GIF-Bild trägt zwar nichts zur graphischen Gestaltung der Seite bei, übernimmt aber die Cookie-Bombardierung und schreibt den Vorgang in eine Logdatei, denn der Image-Tag verweist auf ein CGI-Skript track.pl, das die Drecksarbeit erledigt und anschließend, um den Schein zu wahren, ein Pseudo-Bild zurückschickt:

    <IMG SRC=/cgi-bin/track.pl>

Ein mal ein Pixel

Mit Lincoln Steins Modul GD läßt sich das notwendige Dummy-GIF leicht erzeugen:

    #!/usr/bin/perl -w
    
    use GD;
    my $i  = new GD::Image(1,1);
    my $bg = $i->colorAllocate(0xff,0xdc,0xba);
    print pack('u', $i->gif);

Dieses kurze Skript legt ein neues GIF-Bild in der Farbe #ffdcba (die Hintergrundfarbe von perlmeister.com) im Speicher an und gibt die Daten uuencoded aus:

    C1TE&.#=A`0`!`(```/_<N@```"P``````0`!```"`D0!`#L`

Um Zeit zu sparen, nimmt das CGI-Skript track.pl einfach diesen Buchstabensalat her, und, statt jedesmal GD einzubinden und ein Bild zu erzeugen, legt es die Rohdaten mit einem uudecode nach folgendem Muster frei:

    print unpack('u', $data);

Zeile 49 in unserem Cookie-Tracker track.pl tut genau dies. Doch von vorne: track.pl bindet in Zeile 6 Lincoln Steins praktisches CGI-Modul CGI.pm ein, das die HTML-Ausgaben und die Cookiejongliererei elegant übernimmt.

Die Konstanten in den Zeilen 8 bis 10 definieren die verwendete Cookie-Version (falls sich das Format mal ändern sollte), den Namen des Cookies und den Pfad zur Logdatei.

Zeile 12 testet, ob der Browser dem Request ein Cookie mit dem eingestellten Namen mitgegeben hat. Falls ja, war er offensichtlich schon vorher einmal da und Zeile 14 schreibt den zum nachfolgenden GIF-Bild gehörenden Header, damit der Browser glaubt, es folge lediglich eine belanglose Web-Graphik. Doch in Wirklichkeit analysiert das Skript das ankommende Cookie, das etwa als

    VE100 IP205.134.227.213 CT925961787 ID92596178720836

vorliegt und anzeigt, daß es mit Version 1.00 der Tracker-Software erzeugt wurde, und zwar bei einer Anfrage eines Rechners mit der IP-Adresse 205.134.227.213, zu einem Zeitpunkt, der 925961787 Sekunden nach dem 01.01.1970 lag -- also am 05.05.1999 um 20:36:27 Pacific Standard Time. Die eindeutige ID des Cookies, mittels derer der Server einen einmal angedockten Browser beim nächsten Mal wiedererkennt, ist 92596178720836.

Zeile 20 prüft rudimentär, ob das Format auch ungefähr stimmt und schreibt die Daten im Erfolgsfall in die Logdatei weg, wobei es noch den HTTP_REFERER, also die Seite, auf der das Pseudo-Bild liegt, und den HTTP_USER_AGENT, also den Browsertyp, hinzufügt.

Kommt kein Cookie an, weil der Browser noch nie vorher zu Besuch da war (oder ein Spielverderber das Cookie abblockt), kommt der else-Zweig ab Zeile 26 zum Zug. Dort entsteht das Cookie Schritt für Schritt. Die eindeutige ID besteht aus der aktuellen Sekunden-Uhrzeit mit der angehängten Nummer des gerade laufenden Prozesses ($$).

Zeile 34 sichert den Vorgang in die Logdatei, als erstes Feld steht diesmal ein F für first anstatt wie in Zeile 21 ein R für recur. Geloggt wird in folgendem Format:

    1999/05/05 20:36:27 F 205.134.227.213 92596178720836 \
    925961787 100 http://perlmeister.com/index.html \
    Mozilla/4.07 [en] (X11; I; Linux 2.0.36 i686)

Der cookie-Befehl in den Zeilen 38 bis 42 baut ein standardgemäßes Cookie zusammen und übernimmt praktischerweise die notwendige URL-Maskierung des Cookie-Wertes. Das Verfallsdatum liegt ein Jahr in der Zukunft, sodaß der Browser es in seiner Cookie-Truhe aufbewahrt, falls er ausgeschaltet wird. Abbildung 1 zeigt, wie es ankommt.

Die header-Funktion aus dem CGI-Modul sendet den Header, der ein nachfolgendes GIF ankündigt und bläst das Cookie zum Browser. Damit der Browser das kleine Kontroll-Bildchen nicht etwa im Cache abspeichert und so beim nächsten Zugriff keinen Track-Impuls mehr gibt, schreibt Zeile 46 (wie auch schon im anderen Fall Zeile 15) einen Expire-Header, der ein Jahr in der Vergangenheit liegt (-1y), also glaubt der Cache stets, daß eine veraltete Ausgabe des Bildes vorliegt und wird nicht müde, es weiter fleißig anzufordern.

Zeile 49 schließlich sendet den Inhalt des Ein-Pixel-Bildes -- wie oben besprochen --, indem es die vorher ausbaldowerten uuencode-Daten entpackt.

Ab Zeile 53 ist noch die oben schon genutzte Log-Funktion definiert, die einfach alle ihre Argumente zu einem String zusammenschweißt, das aktuelle Datum und die Uhrzeit davorsetzt und das ganze an die Logdatei anhängt. Wegen der quasi-parallel abgearbeiteten Requests auf einem Webserver synchronisiert logmsg den Zugriff auf die Logdatei mit flock. Um nicht noch 6 Monate vor dem Jahr 2000 noch einen Y2K-Fehler zu machen, zählt Zeile 65 zum Jahr, das localtime zurückliefert, noch 1900 dazu, schon stimmt's. Wer's nicht glaubt, liest's unter [2] nach und setzt sich eine braune Papiertüte auf den Kopf.

Installation

track.pl muß ins cgi-bin-Verzeichnis des Webservers, die eingestellte Logdatei muß für das Skript beschreibbar sein. Im HTML-Code jeder Webseite, die den Cookie-Tracker aktivieren soll, fügt man anschließend ein Image-Tag vom Format

    <IMG SRC=/cgi-bin/track.pl>

ein. Wer den in [1] beschriebenen Includer verwendet, packt das Tag einfach in die eh schon definierte Kopf/Fußzeile und läßt das Skript nochmal über alle Seiten laufen.

Auswertung

In der nächsten Folge werden wir die gesammelten Daten auswerten und einige unerwartete Schlüsse ziehen. Als kleinen Vorgeschmack gibt's heute schon das Analyseprogramm des kleinen Mannes report.pl, das die Logdatei track.dat einliest und feststellt, wieviele alte Freunde von perlmeister.com denn täglich so wiederkehren. Die Ausgabe

    1999/05/04>    0
    1999/05/05>    1
    1999/05/06>    3
    1999/05/07>    5

zeigt die ersten 4 Tage, in denen das Track-Skript in Betrieb war. Nachdem vor dem Tage Null noch keine Besucher registriert wurden, ist das Ergebnis am ersten Tag gleich 0 -- am nächsten Tag kam einer wieder, der schon am Vortag rumschnüffelte. Am dritten Tag waren es drei von den zwei vorhergehenden Tagen und so weiter.

Wie funktioniert's? Zeile 13 in report.pl extrahiert aus jeder Zeile der Log-Datei das erste, das dritte und das fünfte Feld: das Datum im Format YYYY/MM/DD, den Status (R oder F) und die eindeutige Cookie-ID. Der Hash %dates enthält für jeden analysierten Tag einen Schlüssel im Format YYYY/MM/DD, speichert also die Datumsangaben aller untersuchten Tage. Zwei weitere Hashes sind im Gebrauch: %id_seen_today enthält als Keys eine Kombination aus Datum und Cookie ID und kann so für jedes eintrudelnde Cookie sofort feststellen, ob es am selben Tag schon mal da war. Falls der Status des Cookies in der Logdatei "R" ist, hat es der Browser wiedergeschickt. Kann %id_seen_today das Cookie nicht finden, handelt es sich um einen früheren Besucher, der wiedergekehrt ist, der Datumseintrag in %returns_per_date wird um eins hochgezählt.

Ich bin ja schon so gespannt darauf, was wir nächstesmal mit den bis dahin gesammelten Daten anstellen werden -- und hoffe, Ihr schaltet wieder ein: Zum nächsten Perl-Snapshot!

Literatur

[1]
Die Leiden eines Webmasters, Linux-Magazin, November 1998, http://www.linux-magazin.de/ausgabe.1998.11/HTML/html.html

[2]
Hinweise zu Perl und Y2K: http://language.perl.com/news/y2k.html

Abb.1: Das Cookie kommt an.

Listing track.pl

    01 #!/usr/bin/perl -w
    02 ##################################################
    03 # mschilli@perlmeister.com, 1999
    04 # One-Pixel Image Cookie-Tracker
    05 ##################################################
    06 use CGI qw(:standard);
    07 
    08 $VERSION     = "100";
    09 $COOKIE_NAME = "id";
    10 $LOG_FILE    = "/DATA/track.dat";
    11 
    12 if(defined ($v=cookie(-name => $COOKIE_NAME))) {
    13 
    14     print header('-type'    => "image/gif", 
    15                  '-expires' => '-1y');
    16 
    17     my ($ve, $ip, $ct, $id) = 
    18         ($v =~ /VE(\S+) IP(\S+) CT(\S+) ID(\S+)/);
    19 
    20     if(defined $id) {
    21         logmsg("R $ip $id $ct $ve",
    22         $ENV{HTTP_REFERER} || '-', 
    23         $ENV{HTTP_USER_AGENT} || '-');
    24     }
    25 
    26 } else {
    27 
    28     my $ve  = $VERSION;
    29     my $ip  = $ENV{'REMOTE_ADDR'};
    30     my $ct  = time();
    31     my $id  = time() . $$;
    32     my $cookie = "VE$ve IP$ip CT$ct ID$id";
    33 
    34     logmsg("F $ip $id $ct $ve",
    35         $ENV{HTTP_REFERER} || '-', 
    36         $ENV{HTTP_USER_AGENT} || '-');
    37 
    38     $cookie = cookie(
    39       '-name'    => $COOKIE_NAME,
    40       '-value'   => $cookie,
    41       '-expires' => '+1y', -path => '/'
    42     );
    43 
    44     print header('-type'    => 'image/gif',
    45                  '-cookie'  => $cookie,
    46                  '-expires' => '-1y');
    47 }
    48 
    49 print unpack('u', 'C1TE&.#=A`0`!`(```/_<N@```"P`'.
    50              '`````0`!```"`D0!`#L`');
    51 
    52 ##################################################
    53 sub logmsg {
    54 ##################################################
    55     my $msg = join(' ', @_);
    56 
    57     my ($sec,$min,$hour,$mday,$mon,$year) =
    58                                   localtime(time);
    59     open(LOG, ">>$LOG_FILE") || 
    60         die "Cannot open $LOG_FILE";
    61 
    62     flock(LOG, 2);
    63 
    64     printf LOG "%d/%02d/%02d %02d:%02d:%02d ",
    65                $year+1900, $mon+1, $mday,
    66                $hour, $min, $sec;
    67 
    68     print LOG "$msg\n";
    69 
    70     close(LOG);
    71 }

Listing report.pl

    01 #!/usr/bin/perl -w
    02 ##################################################
    03 # mschilli@perlmeister.com, 1999
    04 # Run report on cookie tracker log file.
    05 ##################################################
    06 
    07 open(DATA, "<track.dat") || die "Cannot open";
    08 
    09 while(<DATA>) {
    10 
    11     # Format: date time stat ip id secs ver url ua
    12     my @fields      = split(' ', $_);
    13     my ($date, $stat, $id, $url) = @fields[0,2,4,7];
    14 
    15     $dates{$date} = 1;
    16 
    17     if($stat eq "R" &&
    18        ! exists($id_seen_today{"$date$id"})) {
    19         $returns_per_date{$date}++;
    20         $returns_per_url{$url}++;
    21     }
    22     $id_seen_today{"$date$id"} = 1;
    23 }
    24 
    25 foreach $date (sort keys %dates) {
    26     printf "$date> %4d\n", 
    27            $returns_per_date{$date} || 0;
    28 }
    29 
    30 foreach $url (sort keys %returns_per_url) {
    31     printf "$url> $returns_per_url{$url}\n";
    32 }
    33 
    34 close(DATA);

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.