<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Monolog on Guillaume Delré</title><link>https://guillaumedelre.github.io/tags/monolog/</link><description>Recent content in Monolog on Guillaume Delré</description><generator>Hugo</generator><language>en</language><lastBuildDate>Fri, 15 May 2026 10:00:00 +0000</lastBuildDate><atom:link href="https://guillaumedelre.github.io/tags/monolog/index.xml" rel="self" type="application/rss+xml"/><item><title>No Witnesses</title><link>https://guillaumedelre.github.io/2026/05/15/no-witnesses/</link><pubDate>Fri, 15 May 2026 10:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/05/15/no-witnesses/</guid><description>Part 3 of 8 in &amp;quot;Symfony to the Cloud: Twelve Factors, Thirteen Services&amp;quot;: A service crashed in production and left no logs behind. Here is why fingers_crossed and cloud deployments do not mix well.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>The service had crashed. We had the alert. We had the timestamp down to the second. We had <a href="https://grafana.com/oss/loki/" target="_blank" rel="noopener noreferrer">Loki</a> open and a query ready.</p>
<p>What we didn&rsquo;t have was any logs from the five minutes before the crash.</p>
<p>Promtail was running. It was healthy. It had been collecting logs from every other service without issue. But for this one, in the window that mattered, there was nothing. The service had crashed without leaving a trace.</p>
<h2 id="the-setup-that-looked-correct">The setup that looked correct</h2>
<p>The logging stack was reasonable. Each service wrote structured JSON to stdout using Monolog&rsquo;s logstash formatter:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">stdout</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">type</span>: <span style="color:#ae81ff">stream</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;php://stdout&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">level</span>: <span style="color:#e6db74">&#34;%env(MONOLOG_LEVEL__DEFAULT)%&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">formatter</span>: <span style="color:#e6db74">&#39;monolog.formatter.logstash&#39;</span>
</span></span></code></pre></div><p><a href="https://grafana.com/docs/loki/latest/" target="_blank" rel="noopener noreferrer">Promtail</a> collected container output via the Docker socket, parsed the JSON, extracted labels, pushed to Loki:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">scrape_configs</span>:
</span></span><span style="display:flex;"><span>    -
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">job_name</span>: <span style="color:#ae81ff">docker</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">docker_sd_configs</span>:
</span></span><span style="display:flex;"><span>            -
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">host</span>: <span style="color:#ae81ff">unix:///var/run/docker.sock</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">refresh_interval</span>: <span style="color:#ae81ff">5s</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">pipeline_stages</span>:
</span></span><span style="display:flex;"><span>            -
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">drop</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">older_than</span>: <span style="color:#ae81ff">168h</span>
</span></span><span style="display:flex;"><span>            -
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">json</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">expressions</span>:
</span></span><span style="display:flex;"><span>                        <span style="color:#f92672">level</span>: <span style="color:#ae81ff">level</span>
</span></span><span style="display:flex;"><span>                        <span style="color:#f92672">msg</span>: <span style="color:#ae81ff">message</span>
</span></span><span style="display:flex;"><span>                        <span style="color:#f92672">service</span>: <span style="color:#ae81ff">service</span>
</span></span><span style="display:flex;"><span>            -
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">labels</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">level</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">service</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">relabel_configs</span>:
</span></span><span style="display:flex;"><span>            -
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">source_labels</span>: [ <span style="color:#e6db74">&#39;__meta_docker_container_log_stream&#39;</span> ]
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">target_label</span>: <span style="color:#ae81ff">stream</span>
</span></span></code></pre></div><p>Two stages in that pipeline do more work than the others. The <code>json</code> stage extracts <code>level</code> and <code>service</code> from each log line; the <code>labels</code> stage immediately following promotes them to Loki index labels, making <code>{service=&quot;content&quot;, level=&quot;error&quot;}</code> a direct index lookup rather than a full-text scan across stored lines. The <code>stream</code> relabeling preserves whether a line came from stdout or stderr — a distinction that becomes queryable once Monolog sends errors to stderr and everything else to stdout. The <code>drop older_than: 168h</code> stage is a safety valve: if Promtail restarts after a long gap and replays buffered lines, anything older than seven days is discarded before reaching Loki.</p>
<p>In theory: logs go to stdout, Promtail reads stdout, logs appear in Loki. The <a href="https://12factor.net/logs" target="_blank" rel="noopener noreferrer">twelve-factor app methodology</a> describes exactly this model for Factor XI — treat logs as event streams, write to stdout, let the environment handle collection and routing.</p>
<p>The application had stdout. Promtail was reading stdout. What could go wrong.</p>
<h2 id="what-fingers_crossed-takes-with-it">What fingers_crossed takes with it</h2>
<p>In production, the <code>when@prod</code> block replaced the simple <code>stream</code> handler with something more sophisticated:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">when@prod</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">monolog</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">handlers</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">main</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">type</span>: <span style="color:#ae81ff">fingers_crossed</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">action_level</span>: <span style="color:#ae81ff">error</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">handler</span>: <span style="color:#ae81ff">main_group</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">excluded_http_codes</span>: [<span style="color:#ae81ff">404</span>]
</span></span></code></pre></div><p>The <code>excluded_http_codes: [404]</code> line is itself a tell: without it, every 404 from a scanner or crawler triggers a full buffer flush, dumping megabytes of debug logs for malformed URLs. Someone had already learned that the hard way.</p>
<p><code>fingers_crossed</code> is a well-known Monolog pattern. The idea is elegant: don&rsquo;t flood production logs with debug noise, but if something goes wrong, retroactively show what happened before the error. The handler buffers every log record in memory. The moment it sees an <code>error</code>, it flushes the entire buffer to the nested handler — giving you the full context leading up to the failure.</p>
<p>The problem is what happens when the failure isn&rsquo;t a logged error. It&rsquo;s an OOM kill. A SIGKILL from the orchestrator. A segfault. A process that stops responding and gets forcibly terminated.</p>
<p>In those cases, <code>fingers_crossed</code> never reaches its <code>action_level</code>. The buffer exists, full of the last five minutes of activity, and it vanishes with the process. The logs were there. They were in memory. They died before reaching stdout.</p>
<p>Factor IX of the twelve-factor app talks about disposability: processes should start fast and stop gracefully. On a clean shutdown (SIGTERM), a well-behaved process finishes its current work and exits. But crashes are not clean shutdowns, and memory buffers are not crash-safe. The service had been disposable in the sense that we could restart it; it was not disposable in the sense that its exit was transparent.</p>
<h2 id="the-files-nobody-was-reading">The files nobody was reading</h2>
<p>There was a second problem, quieter but just as persistent.</p>
<p>Every service had a <code>main_group</code> handler that routed logs to two destinations in parallel:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">main_group</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">type</span>: <span style="color:#ae81ff">group</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">members</span>: [<span style="color:#ae81ff">main_file, stdout]</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">main_file</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">type</span>: <span style="color:#ae81ff">stream</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;%kernel.logs_dir%/%kernel.environment%.log&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">formatter</span>: <span style="color:#e6db74">&#34;monolog.formatter.logstash&#34;</span>
</span></span></code></pre></div><p><code>var/log/prod.log</code> was being written on every service, in every environment, including production. The same content that went to stdout also went to a file inside the container. The file grew without rotation. The file was not accessible to Promtail (which read from the Docker socket, not from the container filesystem). The file consumed disk space. Nobody was reading it.</p>
<p>The audit channel was worse:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">audit_file</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">type</span>: <span style="color:#ae81ff">stream</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;%kernel.logs_dir%/audit.log&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">formatter</span>: <span style="color:#e6db74">&#39;monolog.formatter.line&#39;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">audit</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">type</span>: <span style="color:#ae81ff">group</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">members</span>: [<span style="color:#ae81ff">audit_file, stderr]</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">channels</span>: [<span style="color:#e6db74">&#39;audit&#39;</span>]
</span></span></code></pre></div><p>Audit logs went to <code>stderr</code> (visible to Promtail) and to <code>audit.log</code> (not visible to Promtail). The format in the file was a plain line format, not the structured JSON that Promtail expected. In practice, the audit trail existed in two places: one queryable, one buried in a container directory that survived only as long as the container did.</p>
<h2 id="what-factor-xi-actually-requires">What Factor XI actually requires</h2>
<p>The eleventh factor is direct about this: an app should not concern itself with routing or storage of its output stream. It writes to stdout. Everything else is the environment&rsquo;s job.</p>
<p>That means no file handlers in production. Not as a backup. Not for audit trails. Not &ldquo;just in case&rdquo;. The moment an application starts managing files, it takes on responsibility for rotation, retention, disk space, and accessibility — none of which belong inside a container.</p>
<p>The fix for the file handlers is straightforward. In <code>when@prod</code>, remove every <code>*_file</code> handler and every group that includes one. The audit channel gets the same treatment: stderr only, structured JSON, no file:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">when@prod</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">monolog</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">handlers</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">stdout</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">type</span>: <span style="color:#ae81ff">stream</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;php://stdout&#34;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#75715e"># defaults to &#34;warning&#34; — overridable per-deploy via env var for targeted debugging</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">level</span>: <span style="color:#e6db74">&#34;%env(default:default_log_level:MONOLOG_LEVEL__DEFAULT)%&#34;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">formatter</span>: <span style="color:#e6db74">&#39;monolog.formatter.logstash&#39;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">stderr</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">type</span>: <span style="color:#ae81ff">stream</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;php://stderr&#34;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">level</span>: <span style="color:#ae81ff">error</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">formatter</span>: <span style="color:#e6db74">&#39;monolog.formatter.logstash&#39;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">main</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">type</span>: <span style="color:#ae81ff">group</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">members</span>: [<span style="color:#ae81ff">stdout]</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">channels</span>: [<span style="color:#e6db74">&#39;!event&#39;</span>, <span style="color:#e6db74">&#39;!http_client&#39;</span>, <span style="color:#e6db74">&#39;!doctrine&#39;</span>, <span style="color:#e6db74">&#39;!deprecation&#39;</span>, <span style="color:#e6db74">&#39;!audit&#39;</span>]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">audit</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">type</span>: <span style="color:#ae81ff">stream</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;php://stderr&#34;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">level</span>: <span style="color:#ae81ff">debug</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">formatter</span>: <span style="color:#e6db74">&#39;monolog.formatter.logstash&#39;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">channels</span>: [<span style="color:#e6db74">&#39;audit&#39;</span>]
</span></span></code></pre></div><p>stdout for the main channel. stderr for errors and audit. Nothing else. Promtail picks up both via the Docker socket. The container writes nothing to disk. And audit logs are now structured JSON, queryable in Loki alongside everything else.</p>
<h2 id="the-harder-question-about-fingers_crossed">The harder question about fingers_crossed</h2>
<p>The file handlers were easy. <code>fingers_crossed</code> is more nuanced.</p>
<p>The pattern solves a real problem: in a busy production service, logging everything at debug level creates noise and cost. <code>fingers_crossed</code> lets you capture context without paying for it unless something actually goes wrong. It is a reasonable tradeoff when the failure mode you&rsquo;re protecting against is an application-level error (an exception, a 500, a slow query).</p>
<p>It is not a reasonable tradeoff when the failure mode is a process crash. And in a Kubernetes environment, process crashes happen: OOM evictions, liveness probe failures, node pressure. Exactly the cases where you most need the logs.</p>
<p>One approach: keep <code>fingers_crossed</code> but reduce the buffer size. By default it keeps everything since the last reset. Set <code>buffer_size: 50</code> and you cap memory usage, which also limits what gets lost on crash. You won&rsquo;t have the full context, but you&rsquo;ll have the last fifty records. This patches the blast radius rather than removing the root cause: the opacity still depends on an error threshold that may never fire.</p>
<p>Another approach: accept that debug logs are expensive and raise the default log level in production. Then you don&rsquo;t need <code>fingers_crossed</code> at all — if info and above go directly to stdout, nothing is ever buffered.</p>
<p>The approach we landed on: drop <code>fingers_crossed</code>, raise the default level to <code>warning</code>, keep a debug override available via env var for targeted investigation. The logs we care about appear immediately. The ones we don&rsquo;t are never written. Nothing is buffered.</p>
<h2 id="crashes-dont-flush">Crashes don&rsquo;t flush</h2>
<p>Factor XI and Factor IX meet at the same point: a process dying mid-request. <a href="/2026/05/16/the-cache-that-was-lying-to-us/">another article in this series</a>
 described the illusion of a service that worked perfectly on one pod but quietly misbehaved on two. This is the same illusion, one layer up: a service that appeared to log correctly, until the moment it most needed to.</p>
<p>The rule for production Monolog is blunt: if it doesn&rsquo;t reach stdout or stderr before the process exits, it doesn&rsquo;t exist. A file handler inside a container is invisible to the log collector and dies with the pod. A <code>fingers_crossed</code> buffer is invisible to the log collector and dies with the process.</p>
<p>Production tends to create the conditions where you need logs the most — OOM pressure, cascading failures, bad deploys — and those are exactly the conditions where both of these patterns fail you simultaneously. Write to stdout, default to a level that doesn&rsquo;t require buffering, and make the override available for when you actually need to debug something. The logs will be there. They won&rsquo;t be waiting for an error threshold that never fires.</p>
]]></content:encoded></item></channel></rss>