Mike Schilli verfolgt live, wo die Besucher herkommen, die seine Webauftritte abrufen. Statt Google Analytics nutzt er dazu ein simples Terminalprogramm in Go.
Egal, ob User in meinem USA-Blog [1] schmökern oder sich über von mir zusammengestellte Stadtwanderwege [2] in meiner Wahlheimat San Francisco informieren: Es macht mir eine diebische Freude, anhand der Live-Log-Daten auf dem zugehörigen Webserver mitzuverfolgen, wie die Leser durch die Inhalte navigieren. Die Terminal-UI in dieser Ausgabe hängt sich auf dem Webhost an eine oder mehrere Access-Log-Dateien an, liest mit, welche Seiten abgefragt wurden, und zeigt die aktuellen Zugriffe in einer ewig scrollenden Listbox an. Die unterste Zeile der Terminal-UI führt sogar das Ursprungsland eines anfragenden Webclients und noch einige Geodaten des Users auf (Abbildung 1).
Woher ein Web-Request kommt, lässt sich über dessen Source-IP ermitteln. Bevor der Webserver die angeforderte Seite dorthin ausliefert, schreibt er die Adresse zusammen mit dem Datum und Informationen über den Pfad zur abgerufenen Seite in die Log-Datei »access.log« (Abbildung 2).
Nachgeschnüffelt
Über diese IP lässt sich nun ermitteln, aus welchem Teil der Welt die Anfrage an den Webserver kam. Das geht über einen Reverse-Lookup des DNS-Eintrags des Clients hinaus, der im optimalen Fall nur auf die Domain kommt.
Abbildung 3 zeigt die Seite des Anbieters Whatismyipaddress.com, der zur IP des abrufenden Browsers Informationen über den geografischen Standort und den Namen des verwendeten ISPs liefert. Da ich diesen Screenshot im Urlaub im Ort Savannah im Bundesstaat Georgia aufgenommen habe, gibt der Service den ISP der Ferienwohnung (Comcast) aus, und auch der Ort sowie der Bundesstaat stimmen.
Wie funktioniert das? Für ihr Geschäft erwerben Internet-Provider (ISPs) über regionale Internet-Registrare ganze Blöcke von IP-Adressen, die sie im normalen Betrieb üblicherweise dynamisch ihren Kunden zuweisen. Service-Anbieter wie Ipgeolocation [3] sammeln nun die den ISPs zugewiesenen IP-Blöcke in Datenbanken und bieten wiederum ihren Kunden die ISP-Informationen zu einer gegebenen IP-Adresse (Abbildung 4). Der “Developer”-Plan von Ipgeolocation erlaubt bis zu 1000 kostenlose API-Zugriffe pro Tag (30 000 im Monat) und liefert zu einer vorgegebenen IP-Adresse das Ursprungsland, den Bundesstaat und oft den Ort mit Adresse sowie den Namen des ISPs oder VPN-Providers.
Zur Not als Eigenbau
Wie komplex ist nun die Konstruktion eines solchen Tools? Man sollte meinen, dass es für eine alltägliche Aufgabe wie das Auslesen einer Apache-Log-Datei ein brauchbares Go-Paket gäbe, aber dem ist nicht so. Ich leide normalerweise nicht unter dem Not-invented-here-Syndrom, aber auf der Suche nach einem Paket zum Herunterladen stieß ich nur auf närrisches Design und schlechten Code, also schrieb ich zähneknirschend Listing 1.
Listing 1
tap.go
package main
import (
"github.com/hpcloud/tail"
"log"
"regexp"
"time"
)
type LogEvent struct {
dt time.Time
fields []string
}
func tapLog(fileName string, ch chan LogEvent) {
t, err := tail.TailFile(fileName, tail.Config{Follow: true})
if err != nil {
log.Fatalf("%v", err)
}
re := regexp.MustCompile(`(\S+) \S+ \S+ \[(.*?)\] "[^/]*(/.*?)\s`)
go func() {
for {
line := <-t.Lines
matches := re.FindStringSubmatch(line.Text)
if len(matches) != 4 {
log.Fatalf("Invalid line: %s", line.Text)
}
layout := "02/Jan/2006:15:04:05 -0700"
dt, err := time.Parse(layout, matches[2])
if err != nil {
log.Fatalf("Invalid time: %s", matches[2])
}
ch <- LogEvent{dt: dt, fields: matches[1:]}
}
}()
}
Die Funktion »tapLog()« ab Zeile 12 nimmt den Pfad der Access-Log-Datei des Webservers und einen Channel entgegen, öffnet das Log und folgt ihm ähnlich wie »tail -f«, falls der Server im Live-Betrieb weitere Zeilen anhängt. Die verpackt Zeile 30 dann als Wertepaare aus Zeitstempel und Einzelfeldern in eine Struktur vom Typ »LogEvent« und schiebt sie in den Channel, aus dem der Aufrufer sie später bequem herausholt. Beim Aufruf von »tapLog()« mit demselben Ausgabe-Channel, aber einer weiteren Log-Datei, schiebt es die in der zweiten Datei ankommenden Log-Daten ebenfalls dorthin. So verarbeitet das Hauptprogramm später ohne Mühe ein Dutzend Logs verschiedener Webserver auf demselben Host.
Die Log-Datei in Abbildung 2 zeigt das von Apache und ähnlichen Webservern genutzte, recht simple Format. Am Anfang steht die IP-Adresse, es folgen zwei Felder mit Fehlercodes, dann der Zeitstempel und der Zugriffspfad relativ zum Server-Root. Diese Zeilen parst der reguläre Ausdruck in Listing 1 locker.
Listing 2 nimmt eine IP als String entgegen und kontaktiert den API-Service »ipgeolocation.io« unter dem Pfad »/ipgeo«. Mit einem als URL-Parameter mitgeschickten gültigen API-Key antwortet der Server mit den Geodaten zur IP im JSON-Format. Über das Standardpaket net/http holt die Funktion »Get()« in Zeile 20 in Listing 2 die Antwort vom API-Server, und Zeile 24 liest die übers Netz eintrudelnden Daten ein. Das im einfachen Key-Value-Format strukturierte JSON liest Go mit »json.Unmarshal()« in Zeile 29 in die bereitgestellte Map »data« ein. Der von »ipLookup()« zurückgereichte String mit den Geodaten enthält dann Land, Bundesstaat/Provinz, Stadt sowie den Namen des ISPs des Users.
Listing 2
ip.go
package main
import (
"encoding/json"
"errors"
"io/ioutil"
"net/http"
"net/url"
)
func ipLookup(ip string) (string, error) {
key := "57961c3db9ee0883c1893"
u := url.URL{
Scheme: "https",
Host: "api.ipgeolocation.io",
Path: "ipgeo",
}
q := u.Query()
q.Set("apiKey", key)
q.Set("ip", ip)
u.RawQuery = q.Encode()
resp, err := http.Get(u.String())
if err != nil {
return "", err
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
data := map[string]string{}
json.Unmarshal(body, &data)
_, found := data["ip"]
if !found {
return "", errors.New(data["message"])
}
return ip + " " +
data["country_name"] + " " +
data["state_prov"] + " " +
data["city"] + " " +
data["isp"], nil
}
Sparmaßnahmen
Nun sind Abfragen im Sparplan des API-Providers begrenzt, und damit das Tool bei mehreren Zugriffen desselben Nutzers eine IP nicht mehrmals abfragt, bietet es sich an, einmal eingeholte Geoinformationen in einem Cache zu speichern. Findet das Auswertungsprogramm Informationen zu einer IP im Cache, braucht es sie nicht aufwendig einzuholen, sondern darf sie sogleich anzeigen.
Und noch eine weitere Sparmaßnahme kommt zum Einsatz. Auf einem ausgelasteten Webserver kommen die Anfragen oft in sehr kurzen Zeitabständen an. Würde das Tool jede einzelne Request-IP analysieren, wäre erstens das kostenlose Kontingent des Geoapi-Diensts ruckzuck aufgebraucht. Zweitens käme das Tool gar nicht hinterher, denn jede Anfrage an den Geo-Service dauert ein Weilchen. Außerdem könnte kein Mensch die blitzschnell eintrudelnden Ergebnisse verfolgen. Deshalb begrenzt die Funktion »limiter()« ab Zeile 20 in Listing 3 die Anzahl der Requests mit zu analysierenden IPs.
Bremskanal
So soll das Tool nur etwa alle fünf Sekunden eine IP nachsehen und die in der Zwischenzeit ankommenden IPs ignorieren. Das macht »limiter()« mithilfe zweier Channels »in« und »out«. Aus »in« liest das »select«-Kommando in Zeile 30 in einer Endlosschleife die ankommenden Einzelwerte aus und speichert sie in der Variablen »queue«. Einen eventuell dort bereits bestehenden Wert überschreibt es.
Alle fünf Sekunden feuert der Channel eines Tickers aus dem Go-Standardpaket time die aktuelle Uhrzeit ab. In Zeile 33 bekommt »select« das Ereignis mit und setzt die Variable »pause« auf »false«, beendet also den Sekundenschlaf des Tools. In der nächsten Runde der For-Schleife bekommt das die If-Bedingung in Zeile 25 spitz, und Zeile 26 schiebt den zwischengespeicherten Wert aus »queue« in den Ausgangs-Channel »out«. Das hat den gewünschten Effekt, dass die letzte eingetrudelte IP analysiert wird, sobald der Timer abläuft, und nicht erst, wenn die nächste IP ankommt – das könnte bei einem wenig genutzten Webauftritt dauern.
Listing 3
limiter.go
package main
import (
"fmt"
"time"
)
func Memoize(fn func(string) (string, error)) func(string) string {
cache := map[string]string{}
return func(n string) string {
if val, ok := cache[n]; ok {
return val + " (cached)"
}
val, err := fn(n)
if err != nil {
return fmt.Sprintf("err=%v", err)
}
cache[n] = val
return val
}
}
func limiter(in <-chan string, out chan<- string) {
pause := false
ticker := time.NewTicker(5 * time.Second)
queue := ""
for {
if queue != "" && !pause {
out <- queue
queue = ""
pause = true
}
select {
case item := <-in:
queue = item
case <-ticker.C:
pause = false
}
}
}
So bekommt der Aufrufer im Ausgangs-Channel »out« alle fünf Sekunden einen Wert zugeschoben, egal wie viele Werte im Eingangs-Channel »in« in der Zwischenzeit eintrudeln.
Merkliste
Das Caching bereits eingeholter Werte implementiert die Funktion »Memoize()« ab Zeile 6 in Listing 3. Da sich die Geodaten für eine IP eher nicht sporadisch ändern, wäre es verschwenderisch, bei 20 eingehenden Requests von einer IP zwanzig Mal den Geo-Service zu befragen. Vielmehr nimmt »Memoize()« später die Funktion »ipGeo()« entgegen, baut einen Wrapper darum und merkt sich einmal gelieferte Ergebnisse für spätere Aufrufe. Kommt dann später eine IP wieder an, bunkert »Memoize()« das Ergebnis bereits im Cache und braucht den kostenintensiven Lookup gar nicht erst auszuführen. Stattdessen gibt die Funktion dem Aufrufer das vorher gespeicherte Ergebnis zurück.
Als Cache nutzt Zeile 7 in Listing 3 eine Hash-Tabelle vom Typ »map«, die die IP-Strings auf Geo-Strings abbildet. Ohne viel Federlesens ließe sich der Cache unter Zuhilfenahme eines Key-Value-Store-Pakets auf eine persistente Lösung umschreiben. Dazu eignet sich beispielsweise bolt auf Github, das Werte zu Schlüsseln in eine binäre Key/Value-Datenbank schreibt.
Grafisch im Terminal
Für die Top-ähnliche Anzeige in Abbildung 1 spannt das Tool im Terminal eine Text-GUI auf. Listing 4 zieht dazu das schon öfter im Snapshot genutzte Paket termui von Github heran. Zeile 3 importiert das Hauptpaket unter dem Kürzel »t«, damit Zeile 8 in der Funktion »uiStart()« die GUI hochfahren kann. Klappt das, registriert Zeile 12 mit »defer« eine Aufforderung zum Zuklappen der GUI am Ende der Funktion, damit das Terminal nach Abschluss der Applikation den User in die Shell tippen lässt und nicht im Raw-Modus aussetzt.
Listing 4
ui.go
package main
import (
t "github.com/gizak/termui/v3"
"github.com/gizak/termui/v3/widgets"
"log"
)
func uiStart(rowCh chan string, geoCh chan string) {
err := t.Init()
if err != nil {
log.Fatalln("Termui init failed")
}
defer t.Close()
lb := widgets.NewList()
lb.Title = "Logdrill"
lb.TextStyle.Fg = t.ColorBlack
geo := widgets.NewParagraph()
geo.TextStyle.Fg = t.ColorGreen
geo.Text = ""
// window resizing
var width, height int
listSize := func() int { return height - 3 }
resize := func() {
width, height = t.TerminalDimensions()
lb.SetRect(0, 0, width, listSize())
geo.SetRect(0, listSize(), width, height)
t.Render(lb, geo)
}
resize()
uiEvents := t.PollEvents()
for {
select {
case e := <-uiEvents:
switch e.ID {
case "<Resize>":
resize()
case "q", "<C-c>":
return
}
case line := <-geoCh:
geo.Text = line
t.Render(geo)
case line := <-rowCh:
if len(lb.Rows) >= listSize() {
lb.Rows = lb.Rows[0:listSize()]
}
lb.Rows = append([]string{line}, lb.Rows...)
t.Render(lb)
}
}
}
Die Anzeige in Abbildung 1 besteht aus zwei Komponenten, einem mehrzeiligen List-Widget oben und einem Paragraph-Widget als Statuszeile am unteren Rand. In letzterer stehen später die Geodaten der letzten IP-Adresse. Listing 4 definiert dafür in Zeile 13 mit »NewList()« ein List-Widget in »lb«. Zeile 16 erzeugt mit »NewParagraph()« das einzeilige »geo«-Widget.
Fenster flutscht
Falls der Benutzer das Terminalfenster mit der Maus vergrößert oder verkleinert, sollte die GUI dynamisch reagieren und die Dimensionen der Widgets entsprechend anpassen. Dazu bietet das termui-Paket die Funktion »TerminalDimensions()«, die die aktuelle Höhe und Breite des Shell-Fensters in Zeichen angibt.
Zusammen mit einem Resize-Event, das Zeile 34 später abfängt, und der Funktion »resize()« ab Zeile 22 flutscht das wie gewünscht. Fährt die GUI hoch, löst sie in Zeile 28 selbst die »resize()«-Funktion aus und bestimmt die Ausgangspositionen der Widgets. Die Eckpunkte dieser Widget-Rechtecke erwartet termui als X/Y-Koordinaten für die linke obere Ecke sowie als Breite und Höhe des Rechtecks. Das Einzeiler-Widget am unteren Fensterrand erhält nicht nur eine Textzeile zur Anzeige, sondern auch noch einen Rand. Deshalb berechnet »listSize()« die Anzahl der im oberen List-Widget angezeigten Log-Einträge in Zeile 21 als die um drei Einheiten geschrumpfte Terminalhöhe.
Dynamische Füllung
Dynamisch befüllt das UI die Widgets mit Daten aus den Channels »rowCh« und »geoCh«. Anfangs nimmt »uiStart()« sie als Parameter entgegen, über die später das Hauptprogramm laufend Updates zur Anzeige schickt. Neben Signalen wie dem erwähnten Resize-Event fängt die Haupt-Event-Schleife ab Zeile 30 mit ihrer »select«-Anweisung auch die Tastatureingaben des Nutzers ab, sowohl [Strg]+[C] als auch [Q] beenden das Programm.
Der zweite Event-Handler ab Zeile 39 bekommt neue Textzeilen zur Anzeige in der Geosektion am unteren Fensterrand zugespielt. Wann immer neue Geodaten vorliegen, schnappt er sie sich als String aus dem Channel »geoCh«. Er setzt das entsprechende Textattribut im »geo«-Widget und ruft dann die »Render()«-Methode der GUI auf, die das ihr überreichte Widget neu zeichnet. Kommt hingegen eine neue Log-Zeile durch den Channel »rowCh« an, schnappt sie sich der Handler ab Zeile 42 und fügt sie vor dem ersten Element in die Listbox ein. Dazu verkettet »append()« in Zeile 46 ein neues String-Array-Slice mit dem Rest der um eins verkürzten alten Liste.
Damit die Listbox bei langen Log-Dateien nicht unnötig Speicher frisst, verkürzt die Array-Slice-Operation in Zeile 44 ihren Zeilenspeicher um eins, falls er über die angezeigte Länge hinausschießt.
Channel-Rudel
Das Hauptprogramm in Listing 5 verpflichtet die verschiedenen Programmteile in den anderen Listings zur Zusammenarbeit, indem es ein regelrechtes Rudel von Channels erzeugt und befüllt.
So kommen im Channel »logCh« Log-Events an, die die For-Schleife ab Zeile 33 in einer parallel laufenden Goroutine ausliest und formatiert in den Channel »rowCh« einspeist. An dessen Ausgang wiederum lauscht die GUI. Die IP-Adresse pumpt Zeile 34 gesondert in den Channel »limitInCh«, wo sie der Limiter aufschnappt und entweder fallen lässt oder in den Ausgabe-Channel »limitOutCh« transferiert. Von dort holt sich Zeile 27 geowürdige IPs und ruft den gecachten Geo-Lookup »geoCached()« auf. Mit dessen Ergebnis befüllt Zeile 28 wiederum den Channel »geoCh«, wo die GUI nur darauf wartet, den String anzuzeigen. Ein wahres Power-Tool für dynamische Programmflüsse, diese Go-Channels!
Listing 5
logdrill.go
package main
import (
"flag"
"fmt"
"os"
)
func main() {
flag.Parse()
files := flag.Args()
if len(files) == 0 {
fmt.Printf("No input file\n")
os.Exit(1)
}
logCh := make(chan LogEvent)
for _, file := range files {
tapLog(file, logCh)
}
rowCh := make(chan string)
limitInCh := make(chan string)
limitOutCh := make(chan string)
geoCh := make(chan string)
geoCached := Memoize(ipLookup)
go limiter(limitInCh, limitOutCh)
go func() {
for {
select {
case ip := <-limitOutCh:
geoCh <- geoCached(ip)
}
}
}()
go func() {
for ev := range logCh {
limitInCh <- ev.fields[0]
rowCh <- fmt.Sprintf("%s %s %s",
ev.dt.Format("15:04:05"), ev.fields[0], ev.fields[2])
}
}()
uiStart(rowCh, geoCh)
}
Der Dreisprung in Listing 6 baut in der letzten Zeile die Sourcen zum Binary »logdrill« zusammen. Zuvor holt »go mod tidy« die für die Zusatzpakete notwendigen Sourcen von Github ab und kompiliert sie vor. So hängt der Build-Prozess zwar von einer bestehenden Internet-Verbindung und funktionierenden Versionen der verwendeten Pakete auf Github ab, aber ein einmal gefertigtes Binary läuft bis in alle Ewigkeit.
Listing 6
build.sh
$ go mod init logdrill $ go mod tidy $ go build logdrill.go ip.go ui.go tap.go limiter.go
Haben Sie mehrere Webserver auf demselben Host laufen, dürfen Sie deren Access-Logs »logdrill« auf einmal als Parameter mitgeben, gern mit Wildcard über die Shell. So tut sich selbst bei nicht so populären Webauftritten immer etwas. (uba)
Infos
- “Zwei Deutsche in San Francisco”: https://usarundbrief.com
- “Hike This City”: https://hikethiscity.com
- Ipgeolocation: https://ipgeolocation.io









