TL;DR:
Die vielfach beworbene Kombination „cloudflare-exporter + Prometheus + Grafana“ ist Grütze!
Die zuverlässige Lösung heißt: cloudflare-analytics-exporter + Elasticsearch + Grafana!

Langfassung:

In den letzten Monaten und Jahren haben sowohl Benutzer als auch Browserhersteller die Notwendigkeit von Ad- und Trackingblockern erkannt. Die ganzen 3th-Party Tracker – die sowieso ein Unding sind – werden entsprechend immer unzuverlässiger.

Dumm nur, wenn die Redaktion dann aber alle Nase lang Alarm schlägt, wegen angeblicher Traffikeinbrüche, die aber in den Analyticsdaten vom CDN nicht reproduzierbar sind. Da wäre es doch eine gute Idee, der Redaktion direkt Zugang zu den Daten vom CDN zu verschaffen. Immerhin müssen die Requests zwingend da durch – noch näher an „der Wahrheit“ geht nicht.
Mehreren hundert Personen einen – wenn auch nur lesenden – Account für das CDN zu geben, geht aber auch nicht.

Zumindest bei Cloudflare gibt es dafür aber eine praktische Lösung: eine GraphQL-API für Analyticsdaten.

Was es aber noch nicht gibt/gab: die Daten in eine schön aufbereitete Form in der eigenen Infrastruktur zu bekommen!

Wenn du willst, dass etwas richtig gemacht wird…

Nun gibt es zwar diverse „cloudflare-exporter“ Projekte (z.B. das hier), die teilweise sogar von Cloudflare selbst empfohlen werden.
Und die versprechen auch, schön einfach zu sein: nur einen Prometheus-Storage anbinden, das Beispiel-Dashboard im Grafana importieren und los gehts.
Was sie nicht versprechen: akkurate Zahlen zu liefern. Aus Gründen!

Der Hauptgrund heißt Prometheus!

Das liegt an dessen Funktionsweise. Prometheus arbeitet hauptsächlich mit Countern. Die können aber nur in eine Richtung zählen: nach oben. Und – entsprechend ihrem Namen – können sie auch nur eins: zählen. Eine Zuordnung, bspw. zu Timestamps, ist nicht vorgesehen.
Unter anderem auch durch die Art und Weise, wie Prometheus an die Daten kommt: die werden nicht „rein gepumpt“ sondern Prometheus holt die sich in – mehr oder weniger unregelmäßigen – Abständen selber.
Es liegt also nur die Information vor „zum Zeitpunkt des letzten Scraping-Runs hatte der Counter den Wert X“.

Der clouflare-exporter muss also ständig eine Seite mit Metriken bereit stellen, die Prometheus scrapen kann, und auf der die aktuellen Zahlen stehen.

Wer dem Link zur Cloudflare Analytics API schon gefolgt ist, und sich das ein wenig zu Gemüte geführt hat, wird sich jetzt denken: Moment mal. Die API gibt doch überhaupt keine absoluten Zahlen zurück. Sondern nur die Anzahl der Requests (in verschiedenen Kategorien) im angefragten Zeitraum!?
Und überhaupt: „aktuelle Zahlen“ in Bezug auf was?

Um die Prometheus-Logik, von ausschließlich nach oben zählenden Countern, zu bedienen, müsste der Exporter ja die Daten von Cloudflare intern selber noch mal/immer weiter hoch zählen. Die werden das doch nicht so stumpf implementiert haben und einfach darauf hoffen, dass Prometheus die Metriken oft und regelmäßig genug scraped!?

…ähm – doch! Genau das haben die gemacht.

Und das führt

zu entsprechenden Problemen

Denn um auf die oben stehende Frage „Aktuelle Zahlen in Bezug auf was?“ zurück zu kommen: in Bezug zum Start des Exporters!
Und je nachdem wie lange das her ist, und welchen Zeitraum man in Grafana betrachtet, kann somit die absolute Zahl an Requests um mehrere Größenordnungen von den tatsächlichen Werten abweichen.

Aber es kommt noch schlimmer: wenn der Exporter – aus welchen Gründen auch immer – neu gestartet wird, dann fängt er wieder von Vorne an! Was dann wiederum Prometheus durcheinander bringt. Weil die Counter können ja nur hoch zählen. Wenn er jetzt also einen niedrigeren Wert für einen bestehenden Counter findet, dann sagt er sich „Das ist so aber nicht vorgesehen“ – und macht einfach einen neuen Counter auf …mit dem selben Namen!
Damit kommt dann wieder Grafana nicht so richtig klar, und es zerschießt die Dashboards.

Das lässt sich aber noch toppen. Weil der Exporter ja keine Referenz zu irgendwelchen Timestamps hat, unterstützt er auch nicht das Ziehen von zurückliegenden Daten. Während er läuft, zieht er sich immer nur einmal pro Minute die Daten der letzten Minute (mit Offset). Das heißt im Umkehrschluss aber: wenn der Exporter mal ein paar Minuten (oder länger) nicht läuft, dann fehlen die Daten für diesen Zeitraum einfach in den Metriken! Ups.

Und letztlich sind wir auch recht limitiert, was die Auswertung der Daten angeht. Weil die so flach in Prometheus rum liegen, lassen die Felder sich nicht sinnvoll zusammen fassen. So lässt sich zwar ein schönes Diagramm über die Requests pro HTTP-Status Code erstellen – aber bspw. die 5xx, 4xx und den Rest zusammen fassen und nur noch drei kombinierte Graphen darstellen: geht nicht!

…dann mach es selbst!

Die eben geschilderte „Lösung“ ist also auch nicht besser, als die fehleranfälligen 3th-Party Tracker, die wir eigentlich los werden wollen. Und weil ich auch sonst keine Alternative gefunden habe, bleibt mal wieder nix anderes übrig, als selber tätig zu werden.

Zunächst müssen wir den Storage austauschen. Wir haben in der Betrachtung oben ja bereits gelernt, dass Prometheus denkbar ungeeignet für den Usecase ist! Aus Gründen von Support, Zuverlässigkeit und weil wir sowieso schon eine Instanz davon herum stehen haben, nehmen wir etwas gut abgehangenes: Elasticsearch.

Damit bekommen wir die nötige Flexibilität für eine explizite, zeitliche Zuordnung sowie geeignete Struktur der Daten. Zusammen mit der mächtigen Query-Syntax lassen sich dann auch Usecases wie der für die zusammen gefassten HTTP-Status Codes einfach lösen.

Die Cloudflare GraphQL API liefert außerdem bereits ein wunderschön strukturiertes JSON zurück.

Es könnte so einfach sein…

…wenn Grafana nicht so bekackt wäre. Damit die Mappings (bspw. von Status Codes auf Requests) nicht zerpflückt werden, müssen diese im Index als nested fields gespeichert werden.
Das kann Elasticsearch auch schon eine ganze Weile …aber Grafana nicht! Und der Feature-Request dafür ist auch nicht erst seit kurzem offen – sondern seit 2016!
Ja – traurig!

Also müssen wir aus einem wohl strukturiertem Dokument (pro Zone und Minute) mehrere Dutzend bis hunderte kleine erzeugen. So ein Spaß.

Single source of thruth

Eine weitere Herausforderung ergibt sich jetzt allerdings noch durch den umgedrehten Datenfluss.
Die „cloudflare-exporter“ haben die Daten ja nur für sich gesammelt, und Prometheus hat nur einen davon gescraped – egal wie viele da vielleicht noch liefen.
Da wir die Daten jetzt aktiv in Elasticsearch pumpen, müssen wir sicher stellen, dass das nur einmal bzw. von einer Instanz aus geschieht. Sonst sind die Daten wieder falsch.

Wenn wir unsere neue App nur einmal auf einer dedizierten VM aufsetzen, ist das alles kein Thema.
Aber wer will sich schon um das Maintainen einer extra VM kümmern, wenn schon ein Tooling-Stack existiert, der auf einen Kubernetes-Cluster deployed wird?

Der Funktionsumfang des Stack (bzw. dessen Deployment) führt allerdings zu besagten Herausforderungen. So werden Feature-Branches automatisch in einem passenden Namespace deployed, es gibt mehrere Stages und entsprechend viele Cluster, und auch für jede einzelne Stage
können mehrere Cluster existieren – weil Updates der Kubernetes-Version nämlich fehleranfällig sind, und wir deshalb lieber einen komplett neuen Cluster hoch ziehen und dann einfach die DNS-Einträge umbiegen.

Die App muss also wissen wo sie läuft und dann noch einen weiteren Service befragen, ob sie das darf.
Ausschlaggebend dafür sind „Namespace“, „Cluster“ und „Cluster-Flavor“ (also die Kennzeichnungen der Cluster innerhalb einer Stage). Letzteres ist bei uns im Clusternamen enthalten, also können wir das auf zwei Parameter runter dampfen.

Der abzufragende Service wiederum kann denkbar einfach gehalten werden. Denn was uns bei der App neue Randbedingungen beschert hat, kommt uns jetzt zugute.
Die DNS-Records zeigen nämlich immer automatisch auf den „richtigen“ Cluster und Namespace. Entsprechend müssen wir nur beim Deployment den Clusternamen und Namespace in den Service „einbacken“ und der muss dann nur noch die übergebenen Parameter gegen die lokalen Werte checken.
Und weil das so dermaßen unkompliziert ist, brauchen wir dafür auch nichts groß implementieren. Da reicht ein nginx mit 12 Zeilen Config (wenn wir die Leerzeilen nicht mit rechnen):

server {
      listen 80;
      server_name _;
      root html;
      
      if ($arg_cluster != "my-active-cluster") {
        rewrite ^(.*)$ /invalid.txt break;
      }
      if ($arg_namespace != "my-active-namespace") {
        rewrite ^(.*)$ /invalid.txt break;
      }
      
      rewrite ^(.*)$ /valid.txt;
    }

Wenn wir das jetzt noch in ein Helm-Chart mit einer ConfigMap packen, noch valid.txt: "True" und invalid.txt: "False" dazu tun und die „my-active-*“ Werte durch entsprechende Platzhalter ersetzen – dann ist unser „Service“ auch schon fertig!
(OK, ein Deployment mit nem nginx Image, wo wir die ConfigMap an den richtigen Stellen rein mounten brauchen wir auch noch)

Den Service müssen wir in unserer App jetzt nur noch beim Starten und nach jeder Iteration abfragen, und das Concurrency-Problem ist gelöst – sofern nicht jemand auf die Idee kommt nen HPA dran zu packen oder generell die Replicas auf > 1 zu setzen. Denn mehrere PODs im selben Namespace wird dadurch nicht abgefangen.

Unendliches Wachstum?

Da wir keine Wirtschaftsweisen sondern Entwickler sind, wissen wir, dass „unendliches Wachstum“ in einer physikalisch endlichen Welt kein gangbares Konzept ist.
Und das gilt auch für unseren Index im Elasticsearch!

Damit der über die Monate und Jahre nicht immer weiter wächst, bis irgendwann der Speicher knapp wird, brauchen wir hier eine Rollover-Strategie. Elasticsearch bietet mit dem Index Lifecycle Management (ILM) auch schon eine passende Lösung dafür.
Diese müssen wir beim Anlegen des Index nur entsprechend konfigurieren.

Wir brauchen also eine JSON-Config für die Rollover-Policy und das Index-Template. Schließlich soll die App den Index selber anlegen, wenn er noch nicht existiert.
Was wir allerdings außerhalb der App machen müssen: einen User mit passenden Rechten anlegen!

Zusätzlich zu den Admin-Rechte auf das entsprechende Index-Pattern, benötigt der User (bzw. dessen Rolle) auch noch bestimmte Cluster-Rechte – sonst wird das nix mit dem ILM!
Konkret wären das monitor, manage_ilm, manage_index_templates und je nachdem, wie euer Elasticsearch-Cluster und die Policy aufgebaut ist ggfs. noch read_slm bzw. manage_slm und ggfs. read_ccr.

Und da die App ja den Index nur anlegen soll, wenn er noch nicht existiert, brauchen wir noch Rechte, um genau das abfragen zu können. Also monitor und read auf das Index-Patter *.

Ich weiß, was ihr jetzt sagt:

Wo kann ich diesen wundervollen Artikel kaufen?

Das ist ja der Gag: ihr müsst nur auf das github Projekt gehen und das Repo clonen.

Die config.py müsst ihr allerdings noch anpassen.