Serguey Asael Shinder: An exception message should carry the value, not the shape
Most of the time I spend in a stack trace is spent working out what the input was. The exception tells me a rule was broken. It almost never tells me by what, and that is the one thing the code knew for certain at the moment it threw.
if (!TIMEOUT.matcher(raw).matches()) {
throw new IllegalArgumentException("Invalid timeout");
}
That message costs the reader a debugger session. The code had raw in its hand. It also knew what it was hoping for. Neither is in the message.
if (!TIMEOUT.matcher(raw).matches()) {
throw new IllegalArgumentException(
"timeout=%s in %s: expected a number followed by ms/s/m, e.g. 30s"
.formatted(raw, source));
}
Now the line in the log is the answer. Somebody typed 30 s with a space, or PT30S because another field in the same file takes ISO-8601, and the message says so without anyone reproducing anything.

The rule I actually apply
Name the value, where it came from, and what was expected. Three fragments, one line. The value because it is the fact. The origin because in a system with four configuration layers "which file" is half the question. The expectation because the reader is about to guess at it anyway, and a wrong guess costs another round trip.
I write it as a habit now: any throw that mentions a rule mentions the value that broke it.
Where it needs care
Secrets. A password, a token or a key must not travel into a log, and this idea points exactly the wrong way for them. For those fields the useful facts are the shape, not the content: apiKey: expected 64 hex characters, got 32. Length and character class are usually enough to find the mistake, and neither is a secret.
Size. A rejected 8 MB JSON body does not belong in a message. Truncate with the length attached — body (8,412,331 bytes, starts: {"items":[{"id"...) — because the length is often the defect.
Personal data. Same reasoning as secrets, and the log retention policy is the reason. Shape, not content.
Two adjacent ones I keep coming back to
A guard that names its own threshold. IllegalStateException("queue depth 10 000 exceeds max 8 192") reads as a capacity decision. "Queue full" reads as an outage. They are the same event, and the first one tells you which number to change.
A parse failure that gives the position. Files.readString plus a regex loses the line number, and then everyone reads the file by eye. If you are parsing a line at a time, carry the line number into the message. It is one int and it removes the search entirely.
None of this is clever. It is just deciding that the message is written for the person who will read it at 2am, and that person is usually me.