... ach, da laufen sie ja! (Linux-Magazin, August 1999)

Der letzten Monat vorgestellte Cookie-Tracker ([1]) hat einen Monat lang fleißig Daten produziert -- Zeit für die graphische Aufbereitung!

Nochmal schnell zur Wiederholung: Der Cookie-Tracker schreibt jeden Request in der Form

    1999/05/31 14:42:47 F 129.13.216.41 92818696712249 928186967 100 \
    http://perlmeister.com/ Mozilla/4.5 [en] (X11; I; Linux 2.0.36 i586)

in die Logdatei. Steht dort nach dem Datum und der Uhrzeit ein ``F'' für ``First'', sandte also der Browser kein Cookie an den Server, war er entweder noch nie auf perlmeister.com zu Besuch oder unterstüzt das Cookie-Protokoll nicht, sei es, daß der Benutzer es manuell deaktivierte -- oder mit einem Steinzeit-Browser surft.

Für die heutige Analyse der Daten sollen nur die Einträge mit dem ``R'' zählen, denn nur die kommen sicher von Benutzern, die sich identifizieren ließen.

Die rohen Benutzerzahlen, die mir mein Webseitenbetreiber in Form von aufbereiteten Logfiles liefert, kenne ich schon lange -- aber heute wird tiefer gebohrt:

Tiefe Einsichten

Abbildung 1 zeigt die Lösung: Täglich kommen zwischen 0 und 8 Stammkunden wieder, die Balkengraphik oben zeigt die Verteilung über die letzten vier Wochen. Die X-Achse zeigt die Wochentage, über die Y-Achse sind die Benutzerzahlen aufgetragen. Die Werte fallen natürlich viel niedriger aus als die Hit-Zahlen aus der Server-Logdatei glauben machten -- aber immerhin!

Die fliegende Untertasse direkt darunter stellt eine dreidimensionale Tortengrafik dar, die anzeigt, wieviele URLs der durchschnittliche Benutzer sich so zu Gemüte führt. Etwa 25% lassen's mit einer gut sein, weitere 25% klicken noch eins weiter, 15% schaffen drei und so fort bis es schließlich einen kleinen Prozentsatz Unersättlicher gibt, die es auf 10 URLs brachten.

Unten in Abbildung 1 kann man ablesen, wieviel Benutzer einmal im Monat vorbeischauen: Ungefähr 300. Auf zweimal im Monat bringen's ungefähr 25, selbst drei- und viermal kommen manche angedackelt -- obwohl ich meine Seiten im Schnitt nur einmal im Monat auf den neuesten Stand bringe -- hätt' ich nur mehr Zeit, eieiei!

Wie ist es möglich, staubtrockene Logfiles in aussagekräftige Statistiken zu verwandeln? Na, kommt mal her, und laßt's Euch erzählen, von Eurem Perl-Onkel ...

Das in [2] vorgestellte Chart-Paket von David Bonner könnte freilich die graphische Aufbereitung der Daten mühelos übernehmen, rein der Abwechslung halber kommt heute jedoch GIFgraph von Martien Verbruggen dran, das sich ebenfalls auf Lincoln Steins GD-Bibliothek abstützt. Wie's installiert wird, zeigt der Abschnitt ``Installation'' am Ende.

Zunächst geht's ans Einlesen der Logdatei: Listing Logdata.pm zeigt eine Perl-Klasse, die mittels der Methode new eine Logdatei öffnet, zeilenweise durch sie durchgeht, jeden Eintrag in seine Einzelfelder zerlegt, und ihn nur dann indiziert in den Objekt-Speicher übernimmt, falls er nicht länger als eine eingestellte Zeitspanne $ndays zurückliegt.

    use Logdata;
    $log = Logdata->new("track.dat", 30);

im Hauptprogramm liest also alle Records aus der Logdatei track.dat ein, die nicht älter als 30 Tage sind. Hierzu ruft new jedesmal die add-Methode mit den Einzelfeldern als Argumenten auf. add erzeugt aus den Feld-Daten, falls es sich um einen R-Eintrag handelt, eine Unterstruktur in Form eines anonymen Hashs, der die Feldnamen auf die Feldwerte abbildet ("day" => "1999/05/31", "time" => "14:42:47", ...) und hängt eine Referenz darauf in zwei Listen ein, die ganz speziell aufgehängt werden, damit man sie später leicht wiederfindet: Einmal unter die Instanzvariable $self->{ids}, die auf einen Hash verweist, der als Keys IDs (Cookie-Werte) von Benutzern und als Values jeweils eine Liste mit deren Log-Ereignissen enthält. Der Hash ermöglicht es also, daß new() Schritt für Schritt durch die Einträge der Logdatei marschieren kann, um sie jeweils so abzuspeichern, daß man sie später einfach unter der richtigen ID findet. Es reicht, im Hash den zur angegebenen ID passenden Eintrag zu finden, die gefundene Listenreferenz zu dereferenzieren -- schon stehen alle Zugriffe dieses Benutzers zur Verfügung.

Die zweite Liste, in die der Hit-Eintrag wandert, hängt unter einem Hash, der für jeden Tag einen Datumseintrag enthält, an dem ein Benutzer, der vorher schon mal da war, zurückgekehrt ist. Die add-Methode nutzt dazu in den Zeilen 47-50 zwei Hilfs-Hashs, seen_first und seen_today. seen_first enthält für jeden Benutzer den Tag, an dem dieser mit seiner ID zuerst im perlmeister.com-Universum auftauchte. seen_today reiht als Keys jeweils Datum und Benutzer-ID hintereinander, und stellt mittels eines Zählers fest, ob der gleiche Benutzer heute schonmal da war. Hängt also ein Eintrag unterhalb $self->{odays}->{$day}, gehört er einem alten Bekannten, der an diesem Tag zurückkehrte -- wenn wir später über die Einträge dieses Hashs iterieren, kommt für jeden Tag eine Liste mit Hits früherer Besucher heraus, die den Weg zurückgefunden haben, alle doppelten Einträge wurden eliminiert.

Diese Umstrukturierungen machen den Weg frei für die graphische Aufbereitung. Das CGI-Skript stats.pl nutzt die Dienste von Logdata.pm, aber statt sauber über Zugriffsfunktionen auf die Instanzvariablen des Logdata-Objekts zuzugreifen, stochert es direkt darin herum. Schließlich befinden wir uns in Perl-Land -- wo alles erlaubt ist, was Spaß macht.

Wird stat.pl, das CGI-Skript, das die Graphen erzeugt, ohne CGI-Parameter aufgerufen, wie dies üblicherweise mit einem Web-Browser geschieht, springt es in den ersten if-Block ab Zeile 15, gibt einen HTTP-Header für ein HTML-Dokument aus und nutzt die praktischen Funktionen von Lincoln Steins Modul CGI.pm, um drei Überschriften mit den zugehörigen Bildern auszugeben. Die URLs der Bilder verweisen wiederum auf stat.pl, diesmal mit gesetztem CGI-Parameter graph, der die Werte returns, urls und customers annimmt. Ein Aufruf von stat.pl gibt also das HTML-Dokument aus und generiert drei weitere Aufrufe von stat.pl, die jeweils die entsprechenden Graphiken dynamisch erzeugen.

Die Zeilen 11 und 12 in stat.pl definieren zwei globale Variablen -- die Anzahl der zu untersuchenden Tage und den Namen der auszulesenden Logdatei.

Falls der CGI-Parameter graph gesetzt wurde, kommt der else-Zweig ab Zeile 26 zum Zug, der einen HTTP-Header für ein GIF-Bild ausgibt, die Logdatei einliest und die oben erklärte Datenstruktur im Objekt $log erzeugt.

Für den returns-Graphen muß stats.pl zunächst eine Liste mit Checkpoints generieren, für jeden dargestellten Tag eine kleine Zweier-Liste, die als erstes Element das Datum im Format YYYY/MM/DD und als zweites den Wochentag als zweibuchstabige deutsche Abkürzung (Mo, Di, Mi ...) enthalten. Statt dicke Keulen wie die Datumsmanipulationswerkzeuge Date::Calc oder Date::Manip zu schwingen, springt stats.pl einfach tageweise in die Vergangenheit und nutzt Perls localtime-Funktion, die die Unix-übliche Sekunden-seit-1970-Zeit mühelos in ein von Menschen lesbares Format umrechnet. Der map-Befehl in Zeile 43 liefert aus allen Unterlisten jeweils das zweite Element, weist also @days eine Liste mit deutschen Wochentagsabkürzungen zu. Der in Zeile 45 nachfolgende map-Befehl hingegen holt die jeweils ersten Elemente (im Format YYYY/MM/DD), greift damit in den von Logdata.pm erzeugten Hash mit Datumsangaben (Instanzvariable odays) von Zugriffen wiederkehrender Benutzer und ermittelt die Anzahl der Benutzer mit dem scalar-Befehl, der eine Liste in den skalaren Kontext stellt und deswegen ihre Länge zurückliefert.

Zeile 48 erzeugt eine neue Säulengraphik, Zeile 50 setzt die Beschriftung der X- und Y-Achsen und Zeile 53 gibt ein aus den beiden übergebenen Listen generiertes GIF-Bild aus.

Der ab Zeile 56 erzeugte urls-Graph zeigt an, wieviele Benutzer wieviele URLs anforderten. Hierzu klappert stats.pl die nach Benutzer-ID sortierten Log-Einträge in $log->{ids} ab, und nutzt den alten Bauerntrick aus der Perl-FAQ, mittels eines Hashs %urls doppelte Einträge aus einer Liste zu eliminieren, denn schließlich zählt jeder URL nur einmal pro Benutzer. grep liefert eine Liste zurück, deren Länge (wegen des skalaren Kontexts) $nofurls zugewiesen wird -- die Anzahl unterschiedlicher URLs für einen bestimmten Benutzer. Der Hash %users_with_n_urls ordnet dann User-IDs den URL-Zahlen zu, die Schleife ab Zeile 64 modelt diese Daten in die beiden Listen um, die die Daten für die Kuchengraphik enthalten.

Der customers-Graph schließlich gibt an, wieviele Benutzer an wievielen Tagen im Monat aktiv sind -- wieder halten die nach der Benutzer-ID sortierten Log-Daten her, diesmal fliegen doppelte Datumseinträge für einen Benutzer über das grep-Konstrukt raus. $nofudates gibt jeweils an, an wievielen unterschiedlichen Tagen der jeweilige Benutzer zu Besuch da war, der Hash %ids_per_udates ordnet Tageszahlen Benutzerzahlen zu, gibt also an, wieviel Benutzer es jeweils gibt, die einmal, zweimal, n-mal da waren. Da die Graphenfunktion wieder zwei Listen für die X- und Y-Achsen erwartet, dröselt auch hier wieder eine foreach-Schleife den Hash in zwei Listen @days und @customers auf, und wie immer schreibt die plot-Methode das Ergebnis als rohe GIF-Daten.

Abb.1: Der Browser zeigt die Statistik an.

Installation

GIFgraph gibt's auf dem CPAN, die praktische CPAN-Shell lädt's herunter und installiert's:

    perl -MCPAN -eshell
    > install GIFgraph

In report.pl muß noch der Pfad zur Logdatei angepaßt werden, dann muß es ins CGI-Verzeichnis des Webservers wandern und Ausführungsrechte erhalten. Bleibt die Variable $LOG_FILE wie im Listing angegeben, dann muß track.dat, die Logdatei des letztens vorgestellten Cookie-Trackers, mit nach cgi-bin, damit stats.pl sie einlesen kann.

Und Schluß!

Ich hoffe, Ihr seid von der vielen Häscher- und Listerei jetzt nicht vollständig plemplem im Kopf -- denn das Jonglieren mit Logdaten kann ganz schön ans Eingemachte gehen, wenn man Schlüsse jenseits des alltäglichen Logfile-Einerleis ziehen will. Bis zum nächstenmal -- habt Spaß und erholt Euch gut!

Listing Logdata.pm

    01 ##################################################
    02 # 99/06/14 mschilli@perlmeister.com
    03 ##################################################
    04 package Logdata;
    05 
    06 sub new { 
    07 ##################################################
    08     my ($class, $filename, $ndays) = @_;
    09 
    10     my $startpoint = time() - $ndays * 24 * 3600;
    11 
    12     my $self = { ids   => {}, odays => {} };
    13 
    14     bless $self, $class;
    15 
    16     open DATA, "<$filename" or return undef;
    17     while(<DATA>) { 
    18         chop; 
    19         @fields = split(' ', $_);
    20         
    21         use Time::Local;
    22         my($y, $mo, $d, $h, $m, $s) = (split(m#[/ :]#, $_, 6));
    23         my $time = timelocal($s,$m,$h,$d,$mo-1,$y);
    24 
    25         if($time > $startpoint) {
    26             $self->add(@fields); 
    27         }
    28     }
    29     close(DATA);
    30 
    31     $self;
    32 }
    33 
    34 ##################################################
    35 sub add {
    36 ##################################################
    37     my ($self, $day, $time, $stat, $ip, $id, $secs,
    38         $ver, $url, $browser) = @_;
    39 
    40     my $hit = { day  => $day,  'time' => $time, 
    41                 stat => $stat, ip     => $ip, 
    42                 id   => $id,   secs   => $secs, 
    43                 ver  => $ver,  url    => $url, 
    44                 ua   => $ua };
    45 
    46     if($stat eq 'R') {
    47         push(@{$self->{ids}->{$id}},   $hit);
    48 
    49         $self->{seen_first}->{$id} ||= $day;
    50 
    51         if( $self->{seen_first}->{$id} ne $day &&
    52             ! $self->{seen_today}->{"$day$id"}++) {
    53             push(@{$self->{odays}->{$day}}, $hit);
    54         }
    55     }
    56 }
    57 
    58 1;

Listing stats.pl

    01 #!/usr/bin/perl -w
    02 ##################################################
    03 # mschilli@perlmeister.com, 1999
    04 ##################################################
    05 
    06 use Logdata;
    07 use CGI qw/:standard/;
    08 use GIFgraph::pie;
    09 use GIFgraph::bars;
    10 
    11 my $NOF_DAYS = 30;
    12 my $LOG_FILE = "track.dat";
    13 
    14 if(!param('graph')) {
    15   print header, 
    16     start_html('-title' => "Cookie Statistik"),
    17     p("Die letzten $NOF_DAYS Tage ..."),          
    18     h1("Wiederkehrende Kunden"),
    19     img({src => self_url . "?graph=returns"}),
    20     h1("URLs pro Kunde"),
    21     img({src => self_url . "?graph=urls"}),
    22     h1("Benutzerverteilung über aktive Tage"),
    23     img({src => self_url . "?graph=customers"}),
    24     ;
    25 } else {
    26   print header(-type => "image/gif");
    27   my $log = Logdata->new($LOG_FILE, $NOF_DAYS) or
    28                    die "Cannot read logfile";
    29 
    30   if(param('graph') eq "returns") {
    31     my $TODAY    = time();
    32     my @wdays    = qw/So Mo Di Mi Do Fr Sa/;
    33     my @chkpts   = ();
    34     my $offset;
    35 
    36     foreach $offset (0..$NOF_DAYS-1) {
    37       my @L    = localtime $TODAY-$offset*24*3600;
    38       my $date = sprintf "%d/%02d/%02d", 
    39                        1900+$L[5], $L[4]+1, $L[3];
    40       unshift @chkpts, [$date, $wdays[$L[6]]];
    41     }
    42         
    43     my @days      = map { $_->[1] } @chkpts;
    44     my @returners = 
    45         map { scalar @{ $log->{odays}->{$_->[0]}} } 
    46             @chkpts;
    47         
    48     my $graph = GIFgraph::bars->new();
    49         
    50     $graph->set(x_label => 'Wochentage',
    51                 y_label => 'Benutzer',
    52                 'title' => '');
    53     print $graph->plot([\@days, \@returners] );
    54 
    55   } elsif(param('graph') eq "urls") {
    56     foreach $id (keys %{$log->{ids}}) {
    57         
    58       my %urls = ();
    59       $nofurls = grep { ! $urls{$_->{url}}++ }
    60                       @{$log->{ids}->{$id}};
    61         
    62       $users_with_n_urls{$nofurls}++;
    63     }
    64     foreach $n (keys %users_with_n_urls) {
    65       push(@xvals, $n);
    66       push(@yvals, $users_with_n_urls{$n});
    67     }
    68 
    69     my $graph = GIFgraph::pie->new();
    70 
    71     print $graph->plot([\@xvals, \@yvals] );
    72 
    73   } elsif(param('graph') eq "customers") {
    74     foreach my $id (keys %{$log->{ids}}) {
    75       my %uniq = ();
    76       my $nofudates = 
    77               scalar grep { ! $uniq{$_->{day}}++ } 
    78                           @{$log->{ids}->{$id}};
    79       $ids_per_udates{$nofudates}++;
    80     }
    81 
    82     foreach $udates (sort {$a <=> $b} 
    83                           keys %ids_per_udates) {
    84       push(@days, $udates);
    85       push(@customers, $ids_per_udates{$udates});
    86     }
    87     
    88     my $graph = GIFgraph::bars->new();
    89         
    90     $graph->set(x_label => 'Aktive Tage',
    91                 y_label => 'Benutzer');
    92         
    93     print $graph->plot([\@days, \@customers] );
    94   }
    95 }

Referenzen

[1]
Ja wo laufen sie denn?, Michael Schilli -- Linux-Magazin 07/99, http://www.linux-magazin.de/ausgabe.1999.07/xxx/xxx.html

[2]
Balken und Kuchen (Das Chart-Paket), Michael Schilli -- Linux-Magazin 12/97, http://www.linux-magazin.de/ausgabe.1997.12/Chart/chart.html

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.