Aus Linux-Magazin 03/2026

Webtraffic auf Weltkarte anzeigen

© niserin / 123RF.com

Damit Mike Schilli sieht, woher die Zugriffe auf seine Website erfolgen, gibt er sie live auf einer interaktiven Weltkarte aus.

Sobald ich eine neue Ausgabe meines USA-Blogs freischalte und die Massen-E-Mail an meine Leser herausschicke, schaue ich anschließend wie gebannt auf die Logdatei des Webservers (Abbildung 1). Dadurch sehe ich, woher die Zugriffe kommen. Statt auf lange Apache-Logzeilen richte ich meine müden Augen dann allerdings lieber auf die bunte Grafik in Abbildung 2. In Echtzeit zeigt sie auf einer Weltkarte an, in welchen Ländern Menschen die aktuelle Ausgabe konsumieren.

Alle paar Sekunden markiert der Browser die Umrisse des Landes, von dem gerade ein Request ausgeht. Zusätzlich blendet sich eine Tafel ein, die nochmal das Land, die Stadt und den Service-Provider des Nutzers auflistet. Unten steht der Pfad der Seite, die er gerade abruft. Beim nächsten Request poppt ein neues Land hoch, und so geht das weiter.

Geschwindigkeit drosseln

Nun prasseln Requests oft in schneller Abfolge auf den Webserver ein, beispielsweise wenn jemand eine Seite mit 20 Fotos besucht, deren URLs der Browser binnen Sekundenbruchteilen einzeln abruft. Dann muss die Anzeige auf die Bremse treten, um die Augen des Betrachters nicht zu überlasten. Maximal eine Nachricht pro IP-Adresse und pro Minute ist erlaubt, und der Browser frischt die Anzeige maximal alle zwei Sekunden auf.

Abbildung 1: Der Webserver notiert IPs eingehender Requests in der Log-Datei.

Abbildung 1: Der Webserver notiert IPs eingehender Requests in der Log-Datei.

Der Apache-Webserver loggt tatsächlich nur die IP-Adressen der zugreifenden User-Browser (Abbildung 1). Wie Sie von einer IP-Adresse aus dem Internet auf die Geodaten des Users schließt, habe ich in einem früheren Artikel [1] erläutert. Kurz gesagt, ein DNS-Reverse-Lookup genügt nicht, vielmehr gilt es einen Datenhändler anzuzapfen, der Kenntnis über die ISPs weltweit zugewiesener IP-Adressblöcke hat und sie – wenn möglich kostenlos – per API-Call ausliefert.

Auf die lange Bank

Die in den Zeilen der Logdatei gefundenen IPs müssen Sie also per API-Request auflösen. Das dauert ein paar Sekundenbruchteile. Außerdem wäre es unhöflich, im kostenlosen Service-Tier zu vehement auf den Anbieter [2] einzuhämmern. Deswegen drosselt sich die Applikation freiwillig und schickt lediglich alle 10 Sekunden eine Anfrage – ungeachtet dessen, dass die Requests schneller hereinströmen.

Einmal eingeholte Geodaten für eine IP ändern sich nicht mehr, deswegen darf die Anwendung sie zukünftig aus dem Cache holen. So kommen Hobbyisten höchstwahrscheinlich mit weniger als 1000 Abfragen pro Tag aus, was kostenfrei ein API-Key erlaubt, den der Anbieter nach Registrierung ohne Kreditkarte zuschickt.

Abbildung 2: Die interaktive Weltkarte mit Live-Daten im Browser.

Abbildung 2: Die interaktive Weltkarte mit Live-Daten im Browser.

Das bedeutet, dass sich die Verarbeitung einer IP der Logdatei entweder “billig” oder “teuer” gestaltet. Billige IPs stehen bereits im Cache und lassen sich sofort auflösen. Teure erfordern eine API-Anfrage, die nur alle 10 Sekunden erfolgen soll. Damit teure IPs die billigen nicht blockieren, schiebt der Code teure IPs auf die lange Bank. Hinter ihr steckt ein Go-Channel, aus dem eine parallel laufende Goroutine alle 10 Sekunden einen Eintrag holt und die Abfrage losschickt.

Tausendsassa

Das vorgestellte Go-Programm »server« bringt alles unter einen Hut: Es startet einen Webserver auf Port 8081 auf dem lokalen System. Ein dort andockender Browser bekommt die Weltkarte serviert, gezeichnet von der Library »go-echarts« [3] mithilfe von JavaScript. Nach dem Laden der Karte verbindet sich der JavaScript-Code im Browser mittels des Websocket-Protokolls erneut mit dem Server. Abbildung 3 zeigt den Informationsfluss. Durch diese stabile bidirektionale Verbindung schickt der Server nun Nachrichten zu einprasselnden Web-Requests aus der Logdatei »access.log« an die Weltkarte.

Abbildung 3: Der Browser kontaktiert den Webserver auf zwei Kanälen.

Abbildung 3: Der Browser kontaktiert den Webserver auf zwei Kanälen.

Zeilenweise schürfen

Die Logzeilen aus dem Access-Log in Abbildung 2 nimmt das Steuerprogramm »server« auf der Standardeingabe entgegen. Das geschieht entweder lokal mit »cat access.log**|**./server« oder wahlweise mit »ssh« und einem »cat« auf dem Remote-Host, falls die Datei auf einem Shared Host eines Hosting-Services liegt.

Es sucht zu jeder Zeile die IP-Adresse des Abrufers heraus und löst deren Geo-Information auf. Die Geodaten des Landes, der Stadt, des ISPs und des abgerufenen Webpfads befördert es anschließend über den offenen Websocket-Kanal ins JavaScript des darstellenden Browsers. Letzterer schubst die Animation an, die das Land blau ausmalt und die Infotafel erscheinen lässt.

Die Felder jeder Log-Zeile seziert das Paket »apachelogs« von GitHub in Listing 1. Ziel der Funktion »Logtail()« ab Zeile 15 ist es, für jede Log-Zeile einen Wert vom Typ »LogEvent« (definiert ab Zeile 8) zu generieren und durch den Go-Channel »out« zur Weiterverarbeitung zu schicken. Später gehen Variablen dieses Typs auch durch den Websocket-Kanal an den lauschenden JavaScript-Code im Browser. Vorher wird die Go-Struktur in JSON umgewandelt. Zudem geben die »’json:’«-Annotations in der Typ-Definition an, wie die Felder in JSON heißen.

Listing 1

logtail.go

package webspy
import (
  "bufio"
  "github.com/lmorg/apachelogs"
  "io"
  "strings"
)
type LogEvent struct {
  IP      string
  Path    string `json:"path"`
  ISP     string `json:"isp"`
  City    string `json:"city"`
  Country string `json:"country"`
}
func Logtail(r io.Reader, out chan LogEvent) error {
  scanner := bufio.NewScanner(r)
  for scanner.Scan() {
    line := strings.TrimRight(scanner.Text(), " ")
    entry, err, _ := apachelogs.ParseAccessLine(line)
    if err != nil {
      return err
    }
    out <- LogEvent{IP: entry.IP, Path: entry.URI}
  }
  return scanner.Err()
}

Interessanterweise stolpert das Paket »apachelogs« über Zeilen im Access-Log, die mit einem Leerzeichen abschließen, was bei den mir vorliegenden Dateien offensichtlich vorkommt. Aber nicht verzagen – die Funktion »TrimRight()« aus dem Standardpaket »strings« behebt das Problem. Zeile 23 baut aus den geparsten Einträgen »IP« und »URI« einen Wert vom Typ »LogEvent« und schickt ihn durch den hereingereichten offenen Channel »out«.

O’zapft is!

Listing 2 widmet sich mit der Funktion »Tap()« der parallelen Abarbeitung eintrudelnder Requests aus dem von Listing 1 belieferten Eingangs-Channel »inCh«. Das passiert in einer nebenläufigen Goroutine ab Zeile 10, die »Logtail()« aus Listing 1 abruft.

Listing 2

tap.go

package webspy
import (
  "io"
  "time"
)
func Tap(r io.Reader) (chan LogEvent, error) {
  inCh := make(chan LogEvent)
  outCh := make(chan LogEvent)
  exCh := make(chan LogEvent, 100)
  go func() {
    err := Logtail(r, inCh)
    if err != nil {
      panic(err)
    }
  }()
  geo := NewGeo()
  if err := geo.Init(); err != nil {
    return outCh, err
  }
  go worker(geo, exCh, outCh)
  go func() {
      for ev := range inCh {
        if geo.IsExpensive(ev.IP) {
          select {
          case exCh <- ev:
          default: // dropped
          }
        } else {
          process(geo, ev, outCh)
        }
      }
  }()
  return outCh, nil
}
func process(geo *Geo, ev LogEvent, outCh chan LogEvent) {
  res, err := geo.Lookup(ev.IP)
  if err != nil {
    panic(err)
  }
  ev.Country = res["country_name"]
  ev.City = res["city"]
  ev.ISP = res["isp"]
  outCh <- ev
}
func worker(geo *Geo, inCh chan LogEvent, outCh chan LogEvent) {
  ticker := time.NewTicker(10 * time.Second)
  defer ticker.Stop()
  for ev := range inCh {
    if !geo.IsExpensive(ev.IP) {
      continue
    }
    process(geo, ev, outCh)
    <-ticker.C
  }
}

Die »for«-Schleife ab Zeile 22 arbeitet sämtliche ankommenden Log-Events ab. Enthält ein Event eine bislang unbekannte IP-Adresse, liefert »isExpensive()« in Zeile 23 einen wahren Wert, denn das Bearbeiten erfordert einen “teuren” API-Aufruf. Um die Weiterverarbeitung “billiger” IPs an dieser Stelle nicht zu blockieren, fügt Zeile 25 das Event der Warteschlange für die nebenläufige Routine »worker« ab Zeile 45 hinzu, die alle 10 Sekunden einen wartenden Request abarbeitet. Den für die Schlange zuständigen Channel »exCh« haben wir zuvor in Zeile 9 mit 100 Plätzen im Wartebereich angelegt.

Bankrotterklärung

Versucht der Code aus Zeile 25 eine Position 101 in den vollen Wartebereich einzufügen, weil der Webserver die Requests schneller anliefert, als der Bearbeiter mit API-Anfragen hinterherkommt, schlägt das fehl. Da Zeile 25 jedoch innerhalb eines »select«-Konstrukts steht, blockiert der Warte-Channel nicht. Stattdessen greift die »default«-Klausel in Zeile 26. Sie macht zwar rein gar nichts, sorgt aber damit dafür, den angelieferten Request zu überspringen.

Flattern die Requests schneller herein, als der Code sie abarbeitet, hilft nichts mehr, und als Bankrotterklärung fängt die Logik an, Neuzugänge wegzuwerfen. Doch das ist allemal besser, als eine Warteschlange zu erzeugen, die keine Chance besitzt, jemals auf null zu schrumpfen.

Kommt hingegen ein Request mit einer bereits vorher aufgelösten IP-Adresse an, leitet ihn der Aufruf aus Zeile 29 direkt an die Verarbeitungsfunktion »process()« ab Zeile 35 weiter. Die ruft mit »geo.Lookup()« die API des Geo-Anbieters auf und erhält beinahe immer Land, Stadt, und ISP zur gegebenen IP.

Spürnase

Die Kommunikation mit dem Geo-Anbieter wickelt Listing 3 ab. Zeile 20 liest mit der Funktion »Lookup« aus dem Paket »go-murmur« den geheimen API-Key aus der Datei ».murmur« im Home-Verzeichnis des Users aus. Mit diesem Credential setzt Zeile 54 später einen HTTP-Request auf die API ab und bekommt im Erfolgsfall JSON-Daten mit der gesuchten Lokalität zurück. Zeile 62 dröselt mit »Unmarshal()« das ankommende JSON-Format auf und steckt die eingeholten Werte in eine Hashmap »m«, die »APILookup()« an den Aufrufer zurückreicht.

Listing 3

geo.go

package webspy
import (
  "encoding/json"
  "errors"
  "github.com/mschilli/go-murmur"
  "io/ioutil"
  "net/http"
  "net/url"
)
type Geo struct {
  cache  map[string]map[string]string
  APIKey string
}
func NewGeo() *Geo {
  return &Geo{}
}
func (geo *Geo) Init() error {
  geo.cache = map[string]map[string]string{}
  m := murmur.NewMurmur()
  key, err := m.Lookup("api.ipgeolocation.io")
  if err != nil {
    return err
  }
  geo.APIKey = key
  return nil
}
func (geo *Geo) IsExpensive(ip string) bool {
  _, ok := geo.cache[ip]
  return !ok
}
func (geo *Geo) Lookup(ip string) (map[string]string, error) {
  d, ok := geo.cache[ip]
  if ok {
    return d, nil
  }
  d, err := geo.APILookup(ip)
  if err != nil {
    return d, err
  }
  geo.cache[ip] = d
  return d, nil
}
func (geo *Geo) APILookup(ip string) (map[string]string, error) {
  u := url.URL{
    Scheme: "https",
    Host:   "api.ipgeolocation.io",
    Path:   "ipgeo",
  }
  q := u.Query()
  q.Set("apiKey", geo.APIKey)
  q.Set("ip", ip)
  u.RawQuery = q.Encode()
  var m map[string]string
  resp, err := http.Get(u.String())
  if err != nil {
    return m, err
  }
  body, err := ioutil.ReadAll(resp.Body)
  if err != nil {
    return m, err
  }
  json.Unmarshal(body, &m)
  _, found := m["ip"]
  if !found {
    return m, errors.New(m["message"])
  }
  return m, nil
}

Damit das Auflösen das nächste Mal schneller geht und nicht an der Quota des API-Anbieters nagt, steckt Zeile 40 das Ergebnis unter der IP als Schlüssel in die Hashmap »cache«.

Mach langsam!

Anfragen an einen Webserver treten häufig in Schüben auf, da der Browser zum Beispiel Fotos auf einer Seite sofort nach dem Einholen des HTML-Codes nachfordert. Das sähe im Web-Interface relativ chaotisch aus, deswegen begrenzt Listing 4 den Datenfluss auf eine Nachricht per unterschiedlicher IP pro Minute.

Listing 4

limit.go

package webspy
import (
  "sync"
  "time"
)
type Limiter struct {
  mu   sync.Mutex
  last map[string]int64
}
func NewLimiter() *Limiter {
  return &Limiter{
    last: make(map[string]int64),
  }
}
func (lim *Limiter) ShallProceed(ip string, now time.Time) bool {
  minute := now.Unix() / 60
  lim.mu.Lock()
  defer lim.mu.Unlock()
  if lastMinute, ok := lim.last[ip]; ok && lastMinute == minute {
    return false
  }
  lim.last[ip] = minute
  return true
}

Dazu legt es in Zeile 8 eine Hashtabelle »last« an, die IP-Adressen auf Minutenwerte abbildet. Die Minuten zwischen dem Beginn der Unix-Zeit 1970 und der aktuellen Uhrzeit berechnet Zeile 16, indem sie die Unix-Sekunden durch die Zahl 60 teilt. Besteht zu einer vorgegebenen IP schon ein Eintrag und entspricht der gespeicherte Wert dem Minutenwert der aktuellen Uhrzeit, gibt Zeile 20 von »ShallProceed()« einen falschen Wert zurück. Das ist das Signal für den Aufrufer, die aktuelle IP nicht zu melden. Da Gos Hash-Tabellen nicht threadsicher sind, schirmt der Mutex »mu« in Zeile 7 die Funktionen gegen parallele Zugriffe ab, die sonst die internen Strukturen der Map korrumpieren könnten.

Doppelt gemoppelt

Jetzt fehlt noch das Herzstück des Auswertungsservers in Listing 5. Dessen Hauptfunktion »main()« ab Zeile 52 startet gleich zwei Handler für den Webserver auf Port 8081 des Heimcomputers: einen normalen Webserver und einen Websocket-Server. Letzterer fußt gleichfalls auf einer HTTP-Verbindung, bietet aber eine stabile bidirektionale Kommunikation zwischen Client und Server. Nach dem Aufrufen des kompilierten Go-Programms »server« zeigt es »http://localhost:8081«. Das gilt es, in die URL-Zeile des Browsers einzutippen.

Listing 5

server.go

package main
import (
  "net/http"
  "time"
  "webspy"
  "fmt"
  "os"
  "github.com/go-echarts/go-echarts/v2/charts"
  "github.com/go-echarts/go-echarts/v2/opts"
  "github.com/go-echarts/go-echarts/v2/types"
  "github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
  CheckOrigin: func(r *http.Request) bool { return true },
}
func mapPage(w http.ResponseWriter, _ *http.Request) {
  m := charts.NewMap()
  m.RegisterMapType("world")
  m.SetGlobalOptions(
    charts.WithInitializationOpts(opts.Initialization{
      PageTitle: "Web Server Requests By Country",
      Theme:     types.ThemeWesteros,
      ChartID:   "0",
    }),
  )
  m.AddSeries("Hits", []opts.MapData{})
  b, _ := os.ReadFile("map.js")
  m.AddJSFuncs(string(b))
  m.Render(w)
}
func wsHandler(w http.ResponseWriter, r *http.Request) {
  limiter := webspy.NewLimiter()
  c, err := upgrader.Upgrade(w, r, nil)
  if err != nil {
    return
  }
  defer c.Close()
  evCh, err := webspy.Tap(os.Stdin)
  if err != nil {
    panic(err)
  }
  for ev := range evCh {
    if !limiter.ShallProceed(ev.IP, time.Now()) {
      continue // skip
    }
    if err := c.WriteJSON(ev); err != nil {
      return
    }
    time.Sleep(2 * time.Second)
  }
}
func main() {
  http.HandleFunc("/", mapPage)
  http.HandleFunc("/ws", wsHandler)
  fmt.Println("http://localhost:8081")
  http.ListenAndServe(":8081", nil)
}

Dockt daraufhin der Browser am Serverprogramm an, ist der Handler für “/” ab Zeile 53 zuständig, der auf die Funktion »mapPage()« ab Zeile 16 verzweigt. Dort erzeugt Zeile 17 mit der Funktion »NewMap()« des importierten Pakets »go-echarts« eine Weltkarte. Bestimmte Länder animiert später anhand von dynamisch eingetrichterten Daten eine Series farbig. Der JavaScript-Code, der die Animation im Browser steuert und den Kontakt zum Webserver über eine Websocket-Verbindung aufbaut, liegt in einer externen Datei »map.js«. Die liest Zeile 27 in Listing 5 ein und sendet sie mit »AddJsFuncs()« in Zeile 28 an den Browser.

Baut der Browser die Websocket-Verbindung auf, kommt sie auf der Serverseite im Websocket-Handler in Zeile 54 in Listing 4 heraus. Er verzweigt auf die Funktion »wsHandler()« ab Zeile 31, die zunächst in Zeile 33 ein Upgrade der HTTP-Verbindung auf das Websocket-Protokoll anstößt. Anschließend zapft Zeile 38 mit »Tap()« aus Listing 2 den LogEvent-Strom aus dem Access-Log an.

Die »for«-Schleife ab Zeile 42 arbeitet jeden ankommenden Event ab und fragt jeweils den Limiter, ob sich das Bearbeiten überhaupt rentiert. Falls ja, schickt Zeile 46 den Event mit »WriteJson()« über die Websocket-Verbindung zum Browser, wo der Callback »onmessage()« im JavaScript-Code in Zeile 3 in Listing 6 ihn entgegennimmt.

Listing 6

map.js

window.addEventListener("load", function () {
var ws = new WebSocket("ws://" + location.host + "/ws");
ws.onmessage = function (ev) {
  const msg = JSON.parse(ev.data);
  const data = [{
    name: msg.country,
    value: msg.city + "<br>" +
      msg.isp + "<br>" + msg.path}];
  goecharts_0.dispatchAction({
    type: "showTip",
    seriesIndex: 0,
    name: msg.country,
  });
  goecharts_0.setOption({
    series: [{
      name: "Web Requests by Country",
      data: data,
      emphasis: {
        itemStyle: {
          areaColor: "#0080ff",
        },
      },
    }],
    tooltip: {
      formatter: function (params) {
        goecharts_0.dispatchAction({
          type: "highlight",
          seriesIndex: 0,
          name: params.name,
        });
        let res = params.name;
        if(params.data) {
          res += "<br>" + params.data.value;
        }
        return res;
      },
    },
  });
};
});

Zeile 4 in Listing 6 entpackt den JSON-Salat mit »JSON.parse()« und wandelt so die ehemalige Go-Struktur in ein JavaScript-Objekt um. Die »dispatchAction()« ab Zeile 9 zeichnet dafür verantwortlich, dass ein gemeldetes Land auf der Infotafel landet. Dass das zuständige JavaScript-Objekt »goecharts_0« heißt, liegt übrigens daran, dass die ChartID in Zeile 23 von Listing 5 vorher auf 0 gesetzt wurde.

Animiert

Was auf der Infotafel steht, legt der »formatter« für den »tooltip« ab Zeile 25 fest. Der Callback übernimmt außerdem das Einfärben der Umrisse eines gemeldeten Landes – eine weitere »dispatchAction()« macht’s möglich.

Um das Binary »server« zu kompilieren und auszuführen, müssen die Listings 1 bis**4 in einem Verzeichnis lagern. Darin müssen Sie »go mod init webspy; go mod tidy« aufrufen. Die Listings 5 und**6 wandern danach in ein Unterverzeichnis »server«. Wer dort »go build« losschickt, erhält nach einer Weile das Binary »server«. Mit »cat access.log**|**./server« aufgerufen, druckt es die URL aus, auf der der Server lauscht. Abschließend brauchen Sie nur noch einen Webbrowser darauf einzunorden, können sich zurücklehnen und die Show genießen! (uba)

Infos

  1. Michael Schilli, “Gästebuch”: Linux-Magazin 07/2024: https://www.linux-magazin.de/ausgaben/2024/07/snapshot/
  2. API zur Ermittlung der Geo-Location von IPshttps:https://ipgeolocation.io
  3. Go-echarts, “The adorable charts library for Golang”: https://github.com/go-echarts/go-echarts
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 7 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Nach oben