Anleitung: mapper_parsing_exception - failed to parse aus einem ELK-Appender debuggen

Symptom

Im Service-Log taucht regelmäßig auf:

... PerformanceLogRejectedException: mapper_parsing_exception - failed to parse
  at ...ElasticsearchWriter.sendData(...)
  ...

Die Application-Exception-Queue füllt sich mit identischen Einträgen.

Wichtig zum Verständnis: Diese Exception wird in vielen Logback-Appender-Implementierungen konstruiert und in die Exception-Queue gelegt, nicht geworfen. Die Anwendung läuft normal weiter, nur die betroffenen Log-Dokumente gehen verloren. Der Stacktrace ist der Capture-Stack zum Erzeugungszeitpunkt — kein echter Crash.

Außerdem: viele Logger-Bibliotheken serialisieren die ES-Bulk-Antwort verkürzt und droppen das caused_by. Die Meldung im Service-Log enthält die eigentliche Ursache dann nicht. Ohne den nachfolgenden manuellen Replay rätselt man.

Schritt 1 — Den fehlgeschlagenen Body greifen

Der Body wird in der Regel bereits geloggt, aber auf DEBUG-Level. Logging-Level am betroffenen Logger im laufenden Pod anziehen (kein Redeploy nötig):

curl -X POST \
  "http://<pod-host-or-portforward>/actuator/loggers/<logger-name>" \
  -H "Content-Type: application/json" \
  -d '{"configuredLevel":"DEBUG"}'

Den <logger-name> aus dem Stacktrace ableiten (die Klasse, in der die Exception konstruiert wird, ist meist auch der relevante Logger). Ein paar Minuten warten, dann in den Pod-Logs nach einer Zeile suchen, die mit dem rejected Body endet — meist erkennbar an einem failed body: o. Ä. davor. Der Block besteht aus zwei Zeilen:

{"index":{"_index":"<index-name>"}}
{"@timestamp":"...", ... }

Beide zusammen sind das NDJSON, das der Service an /_bulk schicken wollte.

Logging hinterher wieder zurücksetzen:

curl -X POST \
  "http://<pod-host>/actuator/loggers/<logger-name>" \
  -H "Content-Type: application/json" \
  -d '{"configuredLevel":null}'

Schritt 2 — Body als NDJSON-Datei ablegen

cat > /tmp/fail.ndjson <<'NDJSON'
{"index":{"_index":"<index-name>"}}
{"@timestamp":"...", ...}
NDJSON

Pflicht-Details, sonst antwortet ES mit nichts Brauchbarem:

Schritt 3 — Manuell an ES senden, volle Fehlermeldung lesen

ES-URL und Auth aus dem Service-Deployment holen (Env-Var bzw. konfigurierte BasicAuth — selbe Credentials wie die laufende Anwendung):

ES_URL="<scheme://host:port/_bulk>"
U="<es-user>"
P="<es-password>"

curl -sk -u "$U:$P" -H 'Content-Type: application/x-ndjson' \
     --data-binary @/tmp/fail.ndjson "$ES_URL" | jq .

Flags-Cheatsheet:

Wenn die Antwort leer / der Befehl scheinbar nichts tut:

curl -vk -u "$U:$P" -H 'Content-Type: application/x-ndjson' \
     --data-binary @/tmp/fail.ndjson "$ES_URL" 2>&1 | tail -40

-v zeigt HTTP-Status (401 = Auth falsch, 404 = URL falsch, 415 = Content-Type falsch, 200 = OK, dann Body interpretieren).

Schritt 4 — caused_by interpretieren

Antwort hat die Form:

{
  "took": 0,
  "errors": true,
  "items": [{ "index": {
    "status": 400,
    "error": {
      "type": "mapper_parsing_exception",
      "reason": "failed to parse",
      "caused_by": { "type": "...", "reason": "..." }
    }
  }}]
}

Vier häufige caused_by-Muster und ihr Fix:

A) Field-Limit überschritten

"type": "illegal_argument_exception",
"reason": "Limit of total fields [1000] has been exceeded while adding new fields [N]"

→ Index-Schema ist gewachsen, neuer Code emittiert weitere dynamische Keys. Fix sofort, am betroffenen Index:

curl -k -u "$U:$P" -H 'Content-Type: application/json' -XPUT \
  "<scheme://host:port>/<index-name>/_settings" -d '{
    "index.mapping.total_fields.limit": 5000
  }'

Wirkt live, schon im Buffer hängende Dokumente kommen durch.

Dauerhaft für künftige Indizes (Pattern an die Namenskonvention anpassen):

curl -k -u "$U:$P" -H 'Content-Type: application/json' -XPUT \
  "<scheme://host:port>/_index_template/<template-name>" -d '{
    "index_patterns": ["<prefix>-*"],
    "template": {
      "settings": { "index.mapping.total_fields.limit": 5000 }
    }
  }'

Greift erst bei Neuanlage des nächsten Index.

B) Typ-Konflikt (Skalar vs. Objekt)

"reason": "object mapping for [path.to.field] tried to parse field [...] as object, but found a concrete value"

→ Eine Code-Stelle schickt field als String/Zahl, ES hat es schon als Objekt mit Subfeldern gemappt (oder umgekehrt). Das Feld im reason lokalisieren, im Repo nach den Stellen suchen, die es schreiben — dann entweder Code vereinheitlichen oder das Feld via Reindex mit korrektem Mapping neu anlegen.

C) Feldtyp lässt sich nicht ändern

"reason": "mapper [path.to.field] cannot be changed from type [long] to [text]"

→ Erster Wert hat den Typ festgenagelt. ES erlaubt keinen In-Place-Mapping-Change. Optionen: a) Code so anpassen, dass immer der ursprüngliche Typ kommt; b) auf nächsten Index-Rollover warten; c) ignore_malformed: true für das Feld setzen (silently drop).

D) Datumsformat passt nicht

"reason": "failed to parse field [@timestamp] of type [date] ..."
"caused_by.reason": "could not parse date [...]"

→ Default-ES-Date akzeptiert strict_date_optional_time||epoch_millis. Wenn der Logger ein abweichendes Format generiert, ES-seitig das Mapping um die Variante erweitern oder Logger-seitig auf ISO-8601 zwingen.

Schritt 5 — Verifizieren

Nach dem Fix denselben Body nochmal abschicken:

curl -sk -u "$U:$P" -H 'Content-Type: application/x-ndjson' \
     --data-binary @/tmp/fail.ndjson "$ES_URL" | jq '.errors, .items[0].index.status'

Erwartetes Ergebnis:

false
201

→ Service-Logs kontrollieren, die Reject-Exception darf nicht mehr nachkommen.

Diagnose-Toolkit für später

Aktuelles Mapping einsehen:

curl -sk -u "$U:$P" "<scheme://host:port>/<index-name>/_mapping?pretty"

Feldanzahl pro Index (grobe Limit-Annäherung):

curl -sk -u "$U:$P" "<scheme://host:port>/<index-name>/_field_caps?fields=*" \
  | jq '.fields | length'

Diff zwischen zwei Indizes (zeigt Schema-Drift, ideal um zu sehen welche Felder zwischen zwei Zeiträumen neu dazukamen):

for IDX in <index-A> <index-B>; do
  curl -sk -u "$U:$P" "<scheme://host:port>/$IDX/_mapping" \
    | jq -r --arg i "$IDX" '
        .[$i].mappings | paths(.type? != null) as $p
        | ($p | join(".")) + " = " + (getpath($p).type)' \
    | sort -u > "/tmp/$IDX.txt"
done
diff /tmp/<index-A>.txt /tmp/<index-B>.txt

Was strukturell anfällig macht