The disk filled up on a box where logrotate was, on paper, doing its job. The rotated files were all there: app.log.1, app.log.2.gz, the lot, neatly aged out and compressed. And yet du said the live app.log was tiny while the disk was full. The space was being held by a file with no name.
That's the classic shape of it. logrotate renames app.log to app.log.1 and then signals the process to reopen its log. If the process never reopens, it keeps writing to the same open file descriptor, which now points at an inode with no directory entry. You can't see it with ls, but the kernel keeps the inode alive as long as the fd is open, and it grows forever. lsof | grep deleted is how you spot it.
The postrotate script was sending SIGHUP. The app ignored SIGHUP. It had its own ideas about signals and reopening logs simply wasn't one of them, no documented way to ask it to. So the elegant rename-and-reopen dance had one partner who'd never agreed to dance.
The fix when you can't make an app reopen on signal is copytruncate: logrotate copies the file out and then truncates the original in place, so the app's fd stays valid and just carries on at offset zero. You lose a sliver of log lines written in the gap between copy and truncate, which I can live with for an app that won't cooperate. The proper fix is an app that reopens on SIGHUP like everything else, but you ship what you have, and copytruncate is the honest workaround for the one that won't.