Die meisten Leitfäden zum „strukturierten Logging mit OpenTelemetry” setzen stillschweigend
voraus, dass man bereits sauberes JSON mit einem level-Feld und einer trace_id
aussendet. Echte Systeme tun das nicht. Man hat ein Jahrzehnt an Diensten, die reinen Text
loggen, ein Framework, das Stack Traces über 30 Zeilen druckt, und mindestens eine
Komponente, deren Format sich niemand mehr erinnert gewählt zu haben. Man kann nicht alles
umschreiben, und man sollte es auch nicht müssen, nur um seine Logs in eine Pipeline zu
bekommen.
Die gute Nachricht: OpenTelemetry verlangt nichts von dieser Struktur. Eine Log-Zeile ist ein Log-Record, ob sie nun JSON ist oder nicht. Die Arbeit besteht nicht darin, seine Anwendungen umzuformatieren – sie besteht darin, das Durcheinander so wie es ist zu onboarden, das eine Ding zu zähmen, das wirklich kaputtgeht (mehrzeilige Einträge), und nur das zu parsen, was man tatsächlich abfragen muss.
Roh ist in Ordnung: nicht-JSON ist nicht nicht-OTel
Der filelog-Receiver des OpenTelemetry Collectors verfolgt eine Datei und verwandelt jede
Zeile in einen Log-Record, wobei er die ganze Zeile in den body des Records legt. Kein
Schema, kein Parsen, keine Mitarbeit der Anwendung erforderlich. Eine Zeile wie:
2026-07-17T09:14:22Z ERROR checkout-api order 8821 failed: NullPointerException
wird zu einem Log-Record, dessen body genau diese Zeichenkette ist. Das reicht bereits,
um sie zu versenden, zu durchsuchen und aufzubewahren. Struktur ist etwas, das man dort
hinzufügt, wo es sich lohnt – keine Vorbedingung, um loszulegen.
In LinkMesh fügt man dies als File-Tail-Quelle hinzu: Richten Sie sie auf einen Pfad
(oder einen Glob wie /var/log/app/*.log), und sie rendert den filelog-Receiver für Sie.
Die Mehrzeilen-Falle
Hier fällt naives Log-Onboarding auseinander. Ein Stack Trace ist für einen Menschen ein Ereignis und für einen zeilenorientierten Leser 30 Ereignisse:
2026-07-17T09:14:22Z ERROR checkout-api NullPointerException processing order 8821
at com.acme.checkout.OrderService.total(OrderService.java:88)
at com.acme.checkout.OrderService.submit(OrderService.java:41)
Caused by: java.lang.NullPointerException
at com.acme.pricing.Rules.apply(Rules.java:203)
... 14 more
Verfolgt man das mit einer Standardkonfiguration, bekommt man einen Record für die
Kopfzeile und einen Record pro at ...-Zeile – 30 zusammenhanglose Fragmente. Ihre
Metrik „Fehlerrate” zählt einen Vorfall als dreissig. Die Meldung, die zählt (das
Caused by:), wird von der Ausnahme abgeschnitten, die sie erklärt. Darauf zu alarmieren
ist aussichtslos.
Behebe es: den Eintragsbeginn matchen, den Rest falten
Die Behebung ist eine einzige Idee: Sagen Sie dem Receiver, wo ein neuer Eintrag beginnt, und behandeln Sie alles andere als Fortsetzung des vorherigen. Jeder Eintrag im obigen Beispiel beginnt mit einem Zeitstempel, also erledigt es ein Start-of-Entry-Muster, das auf den Zeitstempel matcht:
multiline:
line_start_pattern: '^\d{4}-\d{2}-\d{2}'
Nun falten sich die Kopfzeile und ihre 29 Fortsetzungszeilen zu einem Log-Record, dessen
body den gesamten Stack Trace enthält. Ein Ereignis rein, ein Ereignis raus – Ihre
Fehlerrate ist wieder ehrlich, und das Caused by: reist mit seiner Ausnahme.
In LinkMesh setzt man dies an der File-Tail-Quelle – ein einziges Feld –, und der Sample-Viewer bestätigt es: Der ganze Trace erscheint als ein Record, nicht als zerfetzter Haufen.

Parsen Sie nur, was Sie brauchen – und behalten Sie den rohen Body
Mit intaktem Ereignis will man meist nur gerade genug Struktur, um abzufragen und zu routen:
eine Schweregrad-Ebene, vielleicht einen Dienst oder eine Fehlerklasse. Man muss einen Stack
Trace nicht vollständig „strukturieren” – man braucht level=ERROR als Attribut, um darauf
filtern und alarmieren zu können.
Ein transform-Prozessor (OTTL) erledigt das, ohne den rohen Body anzufassen. Leiten Sie die Ebene aus dem Inhalt ab:
set(attributes["level"], "ERROR") where IsMatch(body, "(?i)error|exception")
set(attributes["level"], "WARN") where attributes["level"] == nil and IsMatch(body, "(?i)warn")
set(attributes["level"], "INFO") where attributes["level"] == nil
Der Body bleibt genau so, wie er ankam – nichts geht verloren –, und man gewinnt ein
sauberes level-Attribut zum Filtern, Routen und Alarmieren.
Die Falle bei jedem Transform ist, ihn blind zu schreiben: eine leicht falsche Bedingung, und
man hat alles falsch gelabelt und findet es in der Produktion heraus. LinkMeshs
Dry-Run-Vorschau schliesst diese Schleife. Öffnen Sie den Transform-Schritt, füttern Sie
ihn mit einem echten mehrzeiligen Record und beobachten Sie Eingabe und Ausgabe nebeneinander
– die Eingabe hat kein level, die Ausgabe hat level = ERROR, ergänzt und hervorgehoben.
Man sieht die Extraktion tatsächlich geschehen, bevor man sie speichert.

Versende es: ein Eintrag pro Ereignis, abfragbar über das geparste Feld
Richten Sie die Pipeline auf ein Grafana Cloud Logs (Loki)-Ziel, und das ganze mehrzeilige
Ereignis landet als ein einziger Loki-Eintrag – der vollständige Stack Trace intakt im Body,
mit Ihrem geparsten level zum Filtern verfügbar. Eine LogQL-Abfrage nach Fehlern liefert
ganze Vorfälle, keine Kopfzeilen-Fragmente:
{service_name="checkout-api"} | level = "ERROR"
otelcol oder Alloy – dasselbe Ergebnis
LinkMesh verwaltet Collectors auf zwei Laufzeiten – dem OpenTelemetry Collector (über OpAMP)
und Grafana Alloy (über remotecfg) – und der Mehrzeilen-Log-Pfad funktioniert auf beiden
gleich. Man konfiguriert die File-Tail-Quelle und den Transform einmal; LinkMesh generiert
die richtige Konfiguration für welche Laufzeit auch immer jeder Collector betreibt. Der
Stack Trace faltet sich zu einem Ereignis, und die level-Extraktion greift identisch,
sodass man nicht an einen Agenten gebunden ist, um saubere Logs zu bekommen.
Der Punkt
Man strukturiert sich nicht in OpenTelemetry hinein – man onboardet die Logs, die man hat, behebt das eine Ding, das wirklich kaputtgeht (mehrzeilig), und parst das eine oder zwei Felder, die sie abfragbar machen. Roh bleibt roh, der Vorfall bleibt ganz, und man kann jede Transformation sehen, bevor sie die Produktion berührt.
