From File Logs to ELK
One of the projects I worked on had a PHP/Nette backend talking to five different third-party APIs. When something went wrong in production, figuring out what had happened meant going through whatever the application had logged.
At the time, our main tool was Tracy, Nette’s debugger. It wrote its logs directly inside the container.
Every redeploy erased them
And when they were still there, it wasn't much better. An hour in Portainer's tiny window, messing around with cat, head -n, tail -n, grep. Three different dialects, none of them searchable. A few examples from back then:
\Tracy\Debugger::log("export failed: {$path}", 'warning');
\Tracy\Debugger::log($e, \Tracy\Debugger::EXCEPTION);
\Tracy\Debugger::log(
sprintf(
'EXPORT - FAIL, job_id=%d, http=%d, trigger=%s',
$id,
$status,
$trigger,
),
'error',
);A sentence. The whole exception object. A homemade key=value string. That was Point A.
I filed the ticket myself and assigned it to myself. Glad I have that option. I started with an ADR: what we had, and where we wanted to go. I kept adding to it while I worked — not exactly by the book — and it became the docs for the whole thing.
1. Monolog + bridge
Once I had the plan, I put Monolog in. That's the usual thing in PHP. The part that mattered was the bridge: a logger interface under Tracy, so the old Debugger::log calls went there too. I didn't touch the call sites. The same line still wrote to the old file, behind a flag. ELK was off. The flag is still on — a local fallback, until I trust ELK enough to turn the files off.
final class TracyMonologLogger implements \Tracy\ILogger
{
public function log($value, $level = self::INFO): ?string
{
$legacyResult = $this->legacyLogger?->log($value, $level);
if ($value instanceof \Throwable) {
$this->logger->log(
self::mapLevel($level),
$value->getMessage(),
['exception' => $value],
);
return $legacyResult;
}
$this->logger->log(self::mapLevel($level), (string) $value);
return $legacyResult;
}
}For most of the work both worlds were live at once:
Tracy\Debugger::log ─┐
├─► Monolog
AppLogger ───────────┘ │
├─► file (legacy)
│
└─► JSON ─► Logstash ─► ELKOld calls and new ones both go into Monolog. From there, they still go to the old file, and — when the flag is up — to ELK as JSON.
2. One format
Then the call sites, one file at a time: an event you can filter on, a human message, fields you can ask for later.
$this->appLogger->info('export_finished', 'Nightly export completed', [
'JobId' => $job->getId(),
'Vendor' => $vendor->getCode(),
'Duration' => ['ElapsedMilliseconds' => $elapsedMs],
'Outcome' => $failed === [] ? 'ok' : 'rejected',
]);That left 77 calls to the new logger, plus a separate audit channel. One Debugger::log is still there — a warning when a file on disk fails to read.
While I was doing the migration I saw the coverage wasn't great. Some cases weren't logged at all. Where it was critical I added it as I went. The rest went into a separate list, to do later.
3. ELK on one client's beta
The rest of the company was already on ELK — .NET services, Serilog. I didn't pick Loki or Datadog. That would be one more place to look.
So the PHP logs had to look like theirs:
{
"@timestamp": "2026-06-22T16:04:11.203Z",
"Level": "Information",
"RenderedMessage": "Nightly export completed",
"Properties": {
"EventName": "export_finished",
"JobId": 18421,
"Vendor": "acme",
"Outcome": "ok"
}
}In Kibana:
Properties.EventName: export_finished and Properties.Outcome: rejectedThey go out over HTTP to Logstash, behind a config flag. I turned the flag on for one client's beta. Once it worked as expected, I turned it on for the rest. Nothing new broke.
The first timestamps were in local time, an hour off from the rest. I put them in UTC.
I also mapped snake_case to PascalCase on the way out, so I could keep writing my own keys. Two weeks later I deleted that and renamed the keys at the call sites. A translation layer means the code says one thing and Kibana another, and the one you keep typing is the wrong one.
At the end I wrote three short pages: the guidelines, the attribute catalog, when to use which event.
So, Point B
Five PRs, two weeks, 52 files. The trail doesn't leave with the deploy anymore. What used to take an hour in Portainer is now one Kibana query. Even colleagues who aren't that technical can run it.
The ADR idea: 10/10 .
.