Log forensics

Recovering Structure From Java toString() Output in Logs

How Java's map, collection, record and Lombok toString formats are built, why they are not JSON, a procedure for converting them back, and the ambiguities that no parser can resolve.

Sooner or later you have to read a production incident out of a log line that contains `{orderId=10241, items=[Item(sku=AB-1, qty=2)], coupon=null}`. It looks close enough to JSON to be annoying. It is not JSON, it can be mechanically converted most of the time, and the times it cannot are worth understanding precisely — because a converter that guesses wrong gives you a plausible object that is not what the program had.

What you are actually looking at

`AbstractMap.toString()` — inherited by `HashMap`, `LinkedHashMap`, `TreeMap` and most others — writes `{` then each entry as `key=value` joined by `, ` then `}`. An empty map is `{}`. `AbstractCollection.toString()` — `ArrayList`, `HashSet`, `ArrayDeque` — writes `[a, b, c]`, and an empty collection is `[]`. Note that the separator is a comma followed by a space in both, which is the only thing distinguishing a separator from a comma inside a value, and it is not much.

Lombok's `@ToString` writes `ClassName(field=value, field=value)`. Round parentheses and a leading class name are the signature. `@ToString(callSuper = true)` nests the parent as `Child(super=Parent(a=1), b=2)`. `@ToString(includeFieldNames = false)` drops the names entirely, giving `Order(10241, Ada)`, which is essentially unrecoverable — you get a list of values with no keys.

Java records generate a third shape: `Point[x=1, y=2]`, with square brackets and a class name. This is easy to confuse with a collection when the class name is cut off by a log truncation. And a class that overrides nothing at all prints `com.example.Order@1b6d3586` — the class name and an identity hash. There is no data in that string; if that is what your log contains, no tool can help and the fix is to add a `toString`.

ShapeProduced byNotes
`{a=1, b=2}``AbstractMap.toString()`Any `Map`; key order depends on the map type
`[a, b, c]``AbstractCollection.toString()``List`, `Set`, `Queue` — the type is not recorded
`Order(id=1, name=Ada)`Lombok `@ToString`Round brackets, class name prefix
`Order(1, Ada)`Lombok with `includeFieldNames = false`No keys at all; not recoverable as an object
`Child(super=Parent(a=1), b=2)`Lombok with `callSuper = true`The `super` pseudo-field is not a real field
`Point[x=1, y=2]`Java recordSquare brackets plus a class name
`com.example.Order@1b6d3586`No `toString` overrideIdentity hash; contains no data
`[I@1b6d3586`An array's default `toString``[I` means `int[]`; use `Arrays.toString`
`(this Map)` / `(this Collection)`A self-referential structureJava's cycle guard; there is no value to recover

Why it is not JSON, point by point

Nothing is quoted. In JSON, quotes are what distinguishes the string `"42"` from the number `42`, and what delimits a value that contains punctuation. In `toString` output there are no quotes at all, so both jobs are unperformed: types are gone, and there is no way to tell where a value ends except by looking for the separator — which might be inside the value.

The separator is `=` rather than `:`, and it is not escaped. A key or a value containing `=` produces output that splits in more than one way. Splitting on the first `=` is the right heuristic and handles the common case of a value like `query=a=b`, but it fails outright on a map key that contains one.

Nulls and empty strings are printed as bare text. A null reference prints `null`; so does the four-character string `"null"`. An empty string prints nothing at all, so `{a=, b=1}` shows a key whose value is either the empty string or something else that stringifies to nothing. And there is no way to express a key whose value is absent, because `toString` only ever prints what is in the map.

A worked recovery

The mechanical part of the procedure is straightforward. Strip the log prefix up to the first structural character. Tokenise, tracking nesting depth across `{}`, `[]` and `()`, and split entries only on a `, ` that occurs at the current depth. Split each entry on its first `=`. Quote every key. Then infer each value's type: a bare `true`/`false` becomes a boolean, text matching a number grammar becomes a number, `null` becomes null, and everything else becomes a string.

That last inference step is exactly where the guessing lives, and it is worth being able to turn off. If you are going to feed the result into something that cares about types, converting every scalar to a string is the honest default — it is wrong in a boring way rather than wrong in a way that passes review.

From a log line to JSON, with the guesses marked
Log line
2026-03-01 12:04:11.238 INFO c.e.checkout.OrderService - created Order(id=10241, customer=Customer(id=77, name=Ada Lovelace, [email protected]), items=[Item(sku=AB-1, qty=2, price=12.99), Item(sku=CD-9, qty=1, price=4.50)], coupon=null, note=, createdAt=2026-03-01T12:04:11Z)

Reconstruction
{
  "id": 10241,
  "customer": {
    "id": 77,
    "name": "Ada Lovelace",
    "email": "[email protected]"
  },
  "items": [
    { "sku": "AB-1", "qty": 2, "price": 12.99 },
    { "sku": "CD-9", "qty": 1, "price": 4.5 }
  ],
  "coupon": null,
  "note": "",
  "createdAt": "2026-03-01T12:04:11Z"
}

What was guessed, in order of how much it matters:
  coupon=null     -> null. Could equally be the String "null".
  note=           -> "". Could be any object whose toString is empty.
  price=4.50      -> 4.5. If it was a BigDecimal, the scale is now lost;
                     "4.50" as a string would have preserved it.
  qty=2           -> number. Could be int, Integer, long, or String "2".
  id=10241        -> number. Same ambiguity.
  createdAt=...   -> string. It was probably an Instant; JSON has no date.
  Order, Customer, Item -> discarded. The only type information in the
                     line, and the JSON object model has nowhere to put it.

The ambiguities that cannot be resolved

The hard case is a string value containing `, `. Consider `{note=hello, world, id=7}`. Two readings are consistent with the text: a map with two entries where `note` is `"hello, world"`, or a map with three entries where the second one has no `=` and is therefore malformed. A parser that prefers the first gets this line right and will mis-split a different line; one that prefers the second gets a parse error on a perfectly ordinary comment field. There is no information in the string that resolves it. The same applies to a value containing `=`, `{`, `[` or `)`: any structural character inside an unquoted value can be read as structure.

The second impossibility is the null family. `null` in the output is a null reference, the string `"null"`, or an object whose `toString` returns `"null"`. An empty span is the empty string or an object that stringifies to nothing. These collapse into the same text before the log is ever written, so no amount of parsing effort recovers them.

The third is type and collection identity. `2` could be any integral type or a string. `[a, b]` could be a `List`, a `Set` or a `Queue` — and if it was a `HashSet`, the order you see is an artefact of hashing and has no meaning at all, so a reader who assumes the first element is significant is reading noise. Likewise a `HashMap`'s key order varies with capacity and with the JVM, so two log lines of the same object can list keys differently.

PrintedPossible originalsRecoverable?
`null`A null reference; the String "null"; an object whose toString is "null"No
(nothing between separators)The empty string; an object that stringifies to nothingNo
`42``int`, `Integer`, `long`, `BigInteger`, or the String "42"No
`4.50``BigDecimal("4.50")`, `double` 4.5, or the String "4.50"No — JSON drops the trailing zero
`true``boolean`, `Boolean`, or the String "true"No
`a`A `char`, a one-character String, or an enum constantNo
A value containing `, `One string value, or two entriesNo — genuinely ambiguous
A value containing `=`A value with an equals sign, or a key/value boundaryUsually — split on the first `=`
`[a, b]``List`, `Set`, `Queue`, or the String "[a, b]"Contents yes, type no; `HashSet` order is meaningless
`Order(...)`Lombok or a hand-written toStringFields yes, the class name has no JSON home

Reading nested and truncated output

Nesting composes cleanly, which is the one thing working in your favour. A map inside a map prints as `{user={name=Ada, id=7}}`, and depth tracking handles it. A list of Lombok objects inside a map entry prints as `items=[Item(sku=AB-1), Item(sku=CD-9)]`, and the same depth tracking handles the mixture of bracket types. If the brackets balance, a converter can almost always find the structure.

Truncation is the common reason they do not balance. Logging frameworks cap message length, and a long object is cut mid-value, leaving unbalanced brackets and a final entry with no closing delimiter. Nothing can recover the missing tail, but the prefix is still parseable: close the open brackets yourself and mark the last entry as incomplete rather than throwing the whole line away. Multi-line objects that a log shipper has split across records need reassembling first — join the continuation lines before you try to parse.

Nesting, truncation and awkward values
Nested maps and lists — recoverable
{user={name=Ada, roles=[admin, ops]}, active=true}
-> {"user": {"name": "Ada", "roles": ["admin", "ops"]}, "active": true}

Truncated by the logging framework — parse the prefix, flag the tail
Order(id=10241, items=[Item(sku=AB-1, qty=2), Item(sku=CD-9, qt
-> {"id": 10241, "items": [{"sku": "AB-1", "qty": 2}]}   plus a warning
   that the input ended inside the second element.

Values that look like structure but are not
  createdAt=Wed Mar 01 12:00:00 CET 2026    Date.toString()
  timeout=PT2H30M                           Duration.toString()
  nickname=Optional[ada]                    a present Optional
  nickname=Optional.empty                   an absent one — not null
  status=SHIPPED                            an enum, or the String "SHIPPED"
  raw=[B@6d06d69c                           a byte[]; the data is gone

Genuinely ambiguous — both readings are consistent
{note=hello, world, id=7}
-> {"note": "hello, world", "id": 7}        or an entry with no '='

Fixing the problem at the source

Structured logging is the direct answer. A JSON encoder for your logging backend — `logstash-logback-encoder` for Logback, `JsonTemplateLayout` for Log4j2 — emits every event as one valid JSON document, so your log aggregator can index fields rather than substrings. Put contextual values in the MDC so they become top-level fields, and pass the object itself as a structured argument rather than interpolating its `toString` into a message. Where that is not available, calling `objectMapper.writeValueAsString(order)` at the log site is a one-line improvement that at least produces parseable output.

Until that change lands, treat converted log output as a lead rather than as evidence. Use it to find the request identifier, then pull the authoritative record from the database or the upstream service. A reconstruction that silently guessed a null for the string `"null"` is exactly the kind of detail that sends an investigation in the wrong direction for an afternoon.

What to remember

  • Identify the producer first: curly braces mean a `Map`, square brackets with a class name mean a record, round brackets with a class name mean Lombok, and `ClassName@hash` means there is no data to recover.
  • Split entries only on a `, ` at the current nesting depth and split each entry on its first `=`; a stack-based converter handles nested and mixed bracket types that a regular expression cannot.
  • Accept that `null`, the string "null", and an empty value are indistinguishable in this format, and that a string containing `, ` cannot be reliably separated from two entries.
  • Treat inferred types as guesses — `4.50` loses its scale, `2` could be a string — and prefer converting every scalar to a string when the result will be consumed by something that cares.
  • Use a reconstruction to find the identifier, then fetch the authoritative record; and fix the source by logging structured JSON with secrets excluded.

Continue with related checks and tools