Systemd-Journal mit jq filtern

Journalctl bietet in der Version 239 noch keine Möglichkeit, Einträge anhand eines Kriteriums auszuschließen. Bei der Recherche bin ich auf jq (Debian-Paket: jq, IRC-Kanal: #jq) gestoßen, das ein Stream-Editor für JSON ist. Ähnlich wie man mit sed einen Strom von Textzeilen verarbeiten kann, lässt sich jq zum Filtern und Modifizieren eines Stroms von JSON-Blöcken verwenden. Da journalctl auch JSON als Ausgabeformat unterstützt, habe ich mir mit beiden einen entsprechenden Filter gebastelt.

Am Anfang stand die Frage, welche Fehlermeldungen seit gestern aufgetreten sind. Eigentlich keine komplizierte Sachen, denn journalctl hat den Filter -perr. Dabei kamen aber sehr viele Meldungen von Synapse, die alle anderen Fehlermeldungen überdeckt haben. Daher brauchte ich also ein »alle Fehlermeldungen seit gestern, ohne die Meldungen von Synapse« und das unterstützt journalctl (noch) nicht.

JSON ist ein leichtgewichtiges Datenformat, das man auch recht gut lesen und von Hand bearbeiten kann. Journalctl unterstützt die Ausgabe der Logeinträge als JSON, mit man nicht den Kampf mit den Zeilenenden bei sed hat, denn viele von den Fehlermeldungen von Synapse enthalten Zeilenumbrüche. Der einfachste Ausdruck für jq ist ., der das komplette Objekt, das eingelesen wurde, weitergibt. Mit der Kommandozeilenoption -C kann man die Einfärbung der Ausgabe aktivieren. Ein jq -C . ist wie ein sed '' – also ein guter Anfang:

journalctl -perr --since yesterday |jq -C . |less

Die Abfrage- und Bearbeitungssprache von jq ist sehr mächtig – schon allein weil ein JSON-Objekt komplexer als eine Textzeile ist und mehr Operationen darauf ermöglicht. Die vollständige Beschreibung gibt es in der Man-Page. Jq bearbeitet die Eingabe wie einen Strom von JSON-Objekten, die verschiedene Stufen einer Verarbeitungskette durchlaufen. In den Stufen kann das Objekt entfernt oder verändert werden und am Ende der Kette wird das Objekt ausgegeben.

Somit ist mein nächstes Ziel, die Entfernung aller Einträge, die von Synapse kommen. Diese sind dadurch gekennzeichnet, dass deren Feld SYSTEMDUNIT den Wert matrix-synapse.service hat. Für die Ausgabe sollen also alle Objekt ausgewählt werden, auf die dies nicht zutrifft. Der Ausruck für jq lautet select(._SYSTEMD_UNIT != "matrix-synapse.service"). Mit dem Term .NAME greift man auf den Wert des Feldes NAME im aktuellen Objekt zu.

Da viele Informationen in den JSON-Objekten nicht notwendig sind und sich die Ausgabe in Textzeilen schöner zu erfassen wäre, soll eben aus dem komplexen JSON-Objekt am Ende eine JSON-Zeichenfolge werden. Hierfür bietet jq Textersetzung (engl. string interpolation) an. Innerhalb von "" kann man mit \(…) beliebige jq-Ausdrücke einbauen. Am Ende soll eine Ausgabe mit Uhrzeit, Dienstname und Meldung entstehen. Der entsprechende Ausdruck ist also "\(._SOURCE_REALTIME_TIMESTAMP) \(.SYSLOG_IDENTIFIER): \(.MESSAGE)".

Die Stufen in der Verarbeitungskette werden bei jq, ähnlich wie in der Shell, mit | (Pipe) getrennt. Da das Ergebnis der gesamten Kette eine Zeichenfolge ist, kann man mit der Option -r die Ausgabe der unnötigen Anführungszeichen unterdrücken.

journalctl -perr --since yesterday |jq -r 'select(._SYSTEMD_UNIT != "matrix-synapse.service")
  | "\(._SOURCE_REALTIME_TIMESTAMP) \(.SYSLOG_IDENTIFIER): \(.MESSAGE)"
' |less

Die Zeitangaben in Mikrosekunden sind jedoch nicht sonderlich lesbar. Die Funktion todate kann diese in ein lesbares Format umwandeln, aber möchte als Eingabe die Sekunden als Zahl haben. Daher braucht es an dieser Stelle eine Unterkette, in der die Werte in dem Feld in eine Zahl umgewandelt und durch eine Million geteilt werden, bevor sich an todate gehen: ._SOURCE_REALTIME_TIMESTAMP | tonumber |. / 1000000 |todate

Bei mir kam dann die Warnung: jq: error (at <stdin>:4885): null (null) cannot be parsed as a number. Offenbar enthalten nicht alle Objekte das Feld SOURCEREALTIME_TIMESTAMP. Aber jq hat den Operator //, wie es ihn auch in Perl gibt bzw. wie man || auch in Javascript verwendet: wenn der linke Ausdruck zu keinem Wert ergibt, wird der rechte Wert genommen. Da die Objekte alle noch ._REALTIMETIMESTAMP enthalten, soll dieser als Ersatzwert verwendet werden. Analog ist es mit .SYSLOG_IDENTIFIER, das auch nicht in allen Objekten vorkommt, weshalb der Ersatz .UNIT verwendet werden soll

journalctl -perr --since yesterday |jq -r 'select(._SYSTEMD_UNIT != "matrix-synapse.service")
  | "\(._SOURCE_REALTIME_TIMESTAMP // .__REALTIME_TIMESTAMP | tonumber |. / 1000000 |todate) \(.SYSLOG_IDENTIFIER // .UNIT): \(.MESSAGE)"
' |less

Damit hatte ich einen Filter, der mir die Fehlermeldungen ohne die Meldungen von Synapse liefert. Gepackt von dem Möglichkeiten, die jq bietet, wollte ich dann die kompletten Meldungen des Tages so reduzieren, dass ich sie überblicken konnte. Hierbei störten die Meldungen der Matrix-Whatsapp-Verbindung, die sich leicht durch Erweiterung des select-Ausdrucks entfernen ließen.

Allerdings gab es auch viele Meldungen von networkd, die mit Switching to DNS begannen, und ich wollte auch nur genau diese entfernen. Hierfür musste ich bei select wieder eine Unterkette einfügen, denn not ist kein Operator bei jq, sondern eine Funktion. Also musste ich den Wert im Feld MESSAGE auf den Beginn prüfen und dieses Ergebnis negieren: .MESSAGE |startswith("Switching to DNS") |not. Am Ende hatte ich den folgenden Ausdruck, der das Log recht übersichtlich gestaltetet:

journalctl --since today |jq -r '
  select(._SYSTEMD_UNIT != "matrix-synapse.service" and ._SYSTEMD_UNIT != "matrix-whatsapp.service"
      and (.MESSAGE |startswith("Switching to DNS") |not))
  | "\(._SOURCE_REALTIME_TIMESTAMP // .__REALTIME_TIMESTAMP | tonumber |. / 1000000 |todate) \(.SYSLOG_IDENTIFIER // .UNIT): \(.MESSAGE)"
' |less

Aktuelle Version eines Projekts bei Github

Von Riot werden kontinuierlich neue Versionen veröffentlicht. Die Aktualisierung meiner Installation wollte ich daher vereinfachen bis hin zu einer vollständigen Automatisierung. Leider habe ich keine beständige URL gefunden, über die ich immer die letzte Version beziehen kann. Aber github hat eine API, über die man Informationen zur letzten Veröffentlichung abfragen kann: https://api.github.com/repos/vector-im/riot-web/releases/latest. Als Antwort bekommt man ein JSON-Objekt, aus dem ich mit jq die notwendigen Informationen extrahiere.

Die Versionsangabe steht im Feld name, das sich leicht mit .name auswählen lässt und da es eine Zeichenfolge ist, lassen sich mit er Option -r die Anführungszeichen unterdrücken:

ver=$(jq -r .name <<<$release_data)

Mit einer Veröffentlichung können mehrere Dateien herausgegeben werden. Deshalb ist in dem Feld assets eine Menge (Array), dessen Elemente das Feld browser_download_url enthalten. Bei einer solchen Kette von Selektionen braucht man keine einzelnen Stufen (|), sondern kann diese aneinander schreiben: .assets[].browser_download_url. Von diesen URLs soll dann diejenige, die auf .tar.gz endet, verwendet werden.

url=$(jq -r '.assets[].browser_download_url|select(endswith(".tar.gz"))' <<<$release_data)

Das gesamte Skript lautet dann:

#!/bin/zsh

# https://github.com/vector-im/riot-web/releases/tag/latest
release_data=$(curl -s 'https://api.github.com/repos/vector-im/riot-web/releases/latest')
ver=$(jq -r .name <<<$release_data)
url=$(jq -r '.assets[].browser_download_url|select(endswith(".tar.gz"))' <<<$release_data)

echo "latest version: $ver"

cd $0:h
tgt=m.jo-so.de

<span class="createlink">version&#41; </span> && exit 0

mv $tgt $tgt.old
mkdir $tgt
curl -Ls $url |tar xzf - -C $tgt --strip=1
mv $tgt.old/config.json $tgt
diff -u $tgt.old/config.sample.json $tgt/config.sample.json

Alternativ dazu bietet jq noch einen anderen Weg zur Auswahl der URL. Mit .. werden alle Elemente in beliebiger Tiefe auswählt; ähnlich dem Operator ** bei Pfaden in der Zsh. Aus einer Menge von Elementen kann man mit der Funktion strings die Zeichenfolgen herausfilter und mit match gegen einen regulären Ausdruck prüfen. Die Abfrage ist damit etwas kürzer (bei anderen Aufgaben kann sie erheblich kürzer werden), aber nicht mehr so zielsicher und robust.

jq -r '..|strings|select(match("http.*\\.tar\\.gz$"))'

Hängt man an einen Feldnamen ein Fragezeichen, so gibt jq keine Fehlermeldung aus, sollte das Feld nicht existieren, sondern null. Um etwas mehr Sicherheit bei der Abfrage zu haben, kann man also aus der Menge aller Elemente, jeweils das Feld browser_download_url auswählen, sollte es vorkommen: ..|.browser_download_url?

Aus dieser Ergebnismenge muss man allerdings die null-Werte entfernen, was mit dem Filter strings oder der Prüfung . != null möglich ist:

jq -r '..|.browser_download_url?|strings|select(endswith(".tar.gz"))'
jq -r '..|.browser_download_url?|select(. != null and endswith(".tar.gz"))'