<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Prometheus | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/prometheus/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/prometheus/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Sun, 04 Oct 2026 06:06:28 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>Prometheus | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/prometheus/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>EKS Version Upgrades Without a Maintenance Window: APIs, Add-Ons and PDB Deadlocks</title>
		<link>https://john-nessime.com/blog/devops/eks-version-upgrades-without-downtime/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 04 Oct 2026 06:06:28 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Amazon EKS]]></category>
		<category><![CDATA[Audit Logging]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Blue/Green Deployments]]></category>
		<category><![CDATA[CloudWatch]]></category>
		<category><![CDATA[Cluster Autoscaler]]></category>
		<category><![CDATA[Cluster Insights]]></category>
		<category><![CDATA[CoreDNS]]></category>
		<category><![CDATA[Deprecated APIs]]></category>
		<category><![CDATA[Drift Detection]]></category>
		<category><![CDATA[EKS Auto Mode]]></category>
		<category><![CDATA[Graceful Shutdown]]></category>
		<category><![CDATA[Karpenter]]></category>
		<category><![CDATA[Node Autoscaling]]></category>
		<category><![CDATA[Node Lifecycle]]></category>
		<category><![CDATA[Pod Disruption Budgets]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[Rollback Plan]]></category>
		<category><![CDATA[Rolling Updates]]></category>
		<category><![CDATA[Runbooks]]></category>
		<category><![CDATA[VPC CNI]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=839</guid>

					<description><![CDATA[<p>The control plane bump is the part AWS handles and almost never breaks. The damage happens in the data plane rollout afterwards. A practical walk through the three failure families that actually stall EKS version upgrades: removed APIs you did not know you were calling, add-on updates that silently discard your configuration, and pod disruption budgets that deadlock the node drain, plus what each escape hatch really costs.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/eks-version-upgrades-without-downtime/">EKS Version Upgrades Without a Maintenance Window: APIs, Add-Ons and PDB Deadlocks</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The control plane upgrade finished in about twenty minutes and nobody noticed. Then the managed node group update went to <code>Updating</code>, sat there, and came back with <code>PodEvictionFailure</code>. Half the group was on the new AMI and half on the old, and the thing blocking the drain was a PodDisruptionBudget somebody wrote eighteen months earlier for a two-replica deployment.</p>



<p class="wp-block-paragraph">That is the shape of most EKS upgrade incidents. The part everyone books a maintenance window for, the control plane bump, is the part AWS handles and almost never goes wrong. The damage happens in the data plane rollout, because of decisions made months earlier that nobody connected to upgrades.</p>



<p class="wp-block-paragraph">This post covers the three failure families that actually break EKS version upgrades: removed APIs you did not know you were calling, add-on updates that silently discard your configuration, and pod disruption budgets that deadlock the drain. For each one: what it looks like, how to catch it beforehand, and what the escape hatch costs.</p>



<h2 class="wp-block-heading">Why the control plane is the easy half</h2>



<p class="wp-block-paragraph">An EKS cluster has two versions that must stay compatible. AWS manages the control plane; you manage the data plane, which in practice means the kubelet version on your nodes.</p>



<p class="wp-block-paragraph">Two constraints shape everything else. In-place upgrades move one minor version at a time, so being three versions behind means three sequential upgrades and three data plane rollouts. And the kubelet cannot run ahead of the API server, so once the control plane moves, the clock starts on getting nodes to follow.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Failure one: the API calls you did not know you were making</h2>



<p class="wp-block-paragraph">Deprecation and removal are different events, and the gap between them is where people get comfortable. An API version gets deprecated, everything keeps working, the warning scrolls past in CI, and several releases later the version is gone and whatever depended on it starts getting 404s.</p>



<p class="wp-block-paragraph">Your own manifests are usually fine; someone fixed those when the warning appeared. What breaks is the third-party agent nobody owns: a metrics collector, an ingress controller, a Datadog or New Relic agent installed by a Helm chart pinned two years ago. These call the API on a watch loop, so they hit removed APIs immediately, and they tend to fail quietly rather than crash-loop.</p>



<h3 class="wp-block-heading">Cluster insights, and what it is actually reading</h3>



<pre class="wp-block-code"><code>aws eks list-insights --cluster-name my-cluster

aws eks describe-insight 
    --cluster-name my-cluster 
    --id a1b2c3d4-5678-90ab-cdef-EXAMPLE11111</code></pre>



<p class="wp-block-paragraph">EKS runs upgrade readiness checks and exposes them through the insights API. The deprecated API check reads your control plane audit logs on a thirty-day rolling window, scanned about once a day. That has a consequence people trip over constantly: after you fix a deprecated call, the insight stays in <code>ERROR</code> until the last matching log entry ages out. The work is done, the check still fails, and if you gate automation on insight status your pipeline is blocked for weeks on a solved problem. Force a fresh scan rather than waiting for the daily one:</p>



<pre class="wp-block-code"><code>aws eks start-insights-refresh --cluster-name my-cluster
aws eks describe-insights-refresh --cluster-name my-cluster</code></pre>



<p class="wp-block-paragraph">The refresh re-reads the window; it does not shorten it. Audit-log detection also only sees APIs that were actually called, so a monthly CronJob or a chart sitting unapplied in a repo never appears. Insights tells you what your cluster did, not what it could do.</p>



<h3 class="wp-block-heading">Ask the API server directly</h3>



<p class="wp-block-paragraph">The API server keeps its own counter, and it names the release that removes each API. Fastest check available:</p>



<pre class="wp-block-code"><code>kubectl get --raw /metrics | grep apiserver_requested_deprecated_apis</code></pre>



<p class="wp-block-paragraph">Read the <code>removed_release</code> label first: it tells you whether this is urgent for the next upgrade or a warning for the one after. Scrape the metric into Prometheus, Grafana Cloud or Amazon Managed Grafana and you get a standing signal instead of a scramble.</p>



<p class="wp-block-paragraph">For the callers, go to the audit logs. With control plane audit logging on, CloudWatch Logs Insights gives you the identity behind each deprecated call:</p>



<pre class="wp-block-code"><code>fields @message
| filter `annotations.k8s.io/deprecated` = "true"</code></pre>



<p class="wp-block-paragraph">The <code>user.username</code> field is what you need. It usually points at a service account in a namespace you did not install, which is your cue to find the chart&#8217;s owner. Static scanners like <code>kubent</code> and <code>pluto</code> cover the other half, the manifests that have not run recently. Use both.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Failure two: add-ons and the conflict resolution you picked once</h2>



<p class="wp-block-paragraph">Managed add-ons cover what a cluster cannot run without: the VPC CNI, CoreDNS, kube-proxy, the EBS CSI driver. They are version-pinned separately from the cluster, and their compatible ranges move with the Kubernetes version. The failure here is rarely the version, though. It is the conflict resolution mode.</p>



<p class="wp-block-paragraph">When you update an add-on, EKS wants to apply its own field values. If you edited those fields in-cluster, that is a conflict, resolved by what you pass:</p>



<ul class="wp-block-list">
<li><code>NONE</code> leaves your value alone and the update may simply fail. Safe, loud, and it stops your upgrade.</li>



<li><code>OVERWRITE</code> reverts your customization to the EKS default. The update succeeds. Your custom Corefile, your CNI tuning, your tolerations, gone, with no error.</li>



<li><code>PRESERVE</code> keeps your value through the update.</li>
</ul>



<p class="wp-block-paragraph"><code>OVERWRITE</code> is the one that hurts, because it is also the one people reach for when an update fails and they want it to stop failing. It is the standard fix when migrating a self-managed add-on to a managed one, so it gets learned there and carried into the update path, where it means something different. A CoreDNS update with <code>OVERWRITE</code> will replace a Corefile carrying your stub zones and forwarders, and resolution for those zones stops cluster-wide while every health check stays green.</p>



<pre class="wp-block-code"><code>aws eks update-addon 
    --cluster-name my-cluster 
    --addon-name coredns 
    --addon-version &lt;compatible-version&gt; 
    --resolve-conflicts PRESERVE</code></pre>



<p class="wp-block-paragraph">One more trap: the same value means different things on create and on update. On create, <code>PRESERVE</code> behaves close to <code>NONE</code>, leaving an existing self-managed add-on alone rather than adopting your settings. Check the operation, not the word.</p>



<p class="wp-block-paragraph">The durable fix is to stop editing add-on resources with <code>kubectl</code> at all. Put customizations in the add-on&#8217;s configuration values so EKS owns them and there is nothing left to conflict over. An afternoon of work, and this failure family goes away permanently.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Failure three: PDB deadlocks and the fifteen-minute drain clock</h2>



<p class="wp-block-paragraph">This is the one that stalls upgrades halfway, and the mechanics matter because the fix depends on where you are stuck. A managed node group update runs in four phases: setup, scale up, upgrade, scale down. On the default strategy, scale up launches replacements before anything is terminated, which is what preserves capacity. Then the upgrade phase does this, per node:</p>



<ol class="wp-block-list">
<li>Pick a node to replace, up to the configured <code>maxUnavailable</code>.</li>



<li>Cordon it once a new node reaches <code>Ready</code>.</li>



<li>Drain the pods off it.</li>



<li>Wait sixty seconds after eviction, then ask the Auto Scaling group to terminate it.</li>



<li>Repeat until nothing is left on the old launch template version.</li>
</ol>



<p class="wp-block-paragraph">Step three has a fifteen minute budget. If pods have not left in that time and you did not pass the force flag, the whole update fails with <code>PodEvictionFailure</code> and you are left with a half-migrated node group.</p>



<h3 class="wp-block-heading">The deadlock nobody sees coming</h3>



<p class="wp-block-paragraph">A PDB says how many pods must stay available during voluntary disruption, and eviction is voluntary disruption. So the arithmetic is simple and, once you see it, obvious: a deployment with two replicas and a budget requiring two available pods can never be evicted. Not slowly. Never. The drain retries until the clock runs out.</p>



<p class="wp-block-paragraph">One replica with a one-pod minimum is the same deadlock in friendlier clothing, as is a percentage budget that rounds up to the full replica count. And if two PDBs select the same pod, the more restrictive one wins, producing deadlocks invisible in either manifest read alone. The pre-flight check is cheap, and I run it before every upgrade:</p>



<pre class="wp-block-code"><code>kubectl get pdb --all-namespaces</code></pre>



<p class="wp-block-paragraph">Read the <code>ALLOWED DISRUPTIONS</code> column, not the configured minimum. Any budget sitting at zero is a drain that will hang. Fix those before touching the node group, by raising replicas or loosening the budget so at least one disruption is permitted.</p>



<p class="wp-block-paragraph">There is a second cause of <code>PodEvictionFailure</code> with nothing to do with PDBs. The upgrade taints nodes as unschedulable, so after eviction the node should be empty. Anything tolerating every taint keeps it non-empty, and the drain never satisfies its exit condition. If your budgets look fine and eviction still stalls, look at what tolerates <code>NoSchedule</code> unconditionally.</p>



<h3 class="wp-block-heading">What the force flag actually costs</h3>



<pre class="wp-block-code"><code>aws eks update-nodegroup-version 
    --cluster-name my-cluster 
    --nodegroup-name my-nodegroup 
    --force</code></pre>



<p class="wp-block-paragraph">Force does not negotiate with budgets. It deletes the pods and terminates the node regardless. For a stateless service behind a load balancer with several replicas, that is fine and the upgrade completes. For a quorum-based system it is a bad idea: if the budget in front of your etcd, Consul, Kafka or ZooKeeper pods is doing its job, forcing past it can take the cluster below quorum. The budget was the protection; force is the decision to ignore it.</p>



<p class="wp-block-paragraph">My rule is that force is reasonable on a node group I know holds only stateless workloads, and never a first response on a mixed one. Reaching for it because you do not know what is on the node is the actual problem.</p>



<h3 class="wp-block-heading">If you run Karpenter, the levers are different</h3>



<p class="wp-block-paragraph">Karpenter has no update phases. It detects drift when a NodePool or EC2NodeClass stops matching a node&#8217;s configuration, then replaces the drifted nodes. Disruption budgets control the pace and can be scheduled, which is how you build a maintenance window without booking one:</p>



<pre class="wp-block-code"><code>spec:
  disruption:
    budgets:
      - nodes: "0"
        reasons: ["Drifted"]
        schedule: "0 6 * * mon-fri"
        duration: 12h</code></pre>



<p class="wp-block-paragraph">Two things bite people. Schedules are evaluated in UTC, so a window that looks like business hours to you may not be. And any period not covered by a budget is unrestricted, so gaps are not &#8220;no disruption&#8221;, they are &#8220;unlimited disruption&#8221;.</p>



<p class="wp-block-paragraph">The other lever, <code>terminationGracePeriod</code> on the NodePool, overrides everything. Once it elapses, remaining pods are force-deleted regardless of PDBs or the <code>karpenter.sh/do-not-disrupt</code> annotation. It is a deadlock breaker, not a safety setting, and if it is shorter than your budget schedule you can get forced terminations inside a window you thought was protected.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The rollback window changes the calculation</h2>



<p class="wp-block-paragraph">Upstream Kubernetes has no control plane rollback. That single fact is why so many teams built heavyweight approval processes around upgrades. EKS now supports reverting an in-place control plane upgrade to the previous minor version within a seven day window, preserving etcd data and running workloads. It covers in-place upgrades only, which makes sense: with blue/green the old cluster is still there and rollback is a DNS change.</p>



<p class="wp-block-paragraph">What changes is the shape of the risk. An upgrade used to be a one-way door, so the rational response was exhaustive testing beforehand. Now the first week is the test, under real traffic, with a way back. That argues for upgrading more often on smaller jumps, and treating the week after as an active watch period rather than a closed ticket.</p>



<p class="wp-block-paragraph">It is not a general undo. Once the window closes, or you upgrade again, the previous version is gone, and it does not roll back add-ons or application state. Blue/green still has a place when you are several versions behind, since sequential in-place hops multiply exposure. The cost is running two clusters during migration, worth modelling first in Cost Explorer with both tagged, or in a tool like Vantage or CloudZero if you already run one.</p>



<h2 class="wp-block-heading">Troubleshooting a stalled or failed upgrade</h2>



<p class="wp-block-paragraph">When a node group update fails, the reason comes back as an error code. Start there rather than guessing:</p>



<pre class="wp-block-code"><code>aws eks describe-nodegroup 
    --cluster-name my-cluster 
    --nodegroup-name my-nodegroup</code></pre>



<ul class="wp-block-list">
<li><strong>PodEvictionFailure.</strong> A budget blocked the drain, or something tolerates all taints. Check <code>ALLOWED DISRUPTIONS</code> first, then tolerations.</li>



<li><strong>NodeCreationFailure in scale up.</strong> New nodes never reached <code>Ready</code>. Usually no capacity for your instance type in an Availability Zone, an EC2 instance limit in the account, or custom user data breaking the bootstrap.</li>



<li><strong>Slow bootstrap.</strong> A node has fifteen minutes to join, measured from when the Auto Scaling group size increases. Heavy user data, large images pulled at boot or a slow init path push past it.</li>



<li><strong>Pods stuck terminating with no budget in sight.</strong> Look for blocked I/O. A pod with an NFS mount whose server is unreachable will not terminate, and eviction pressure changes nothing.</li>



<li><strong>Insights still in ERROR after the fix.</strong> The thirty day audit window. Refresh, confirm another way, move on.</li>
</ul>



<p class="wp-block-paragraph">One structural note: if the Cluster Autoscaler scales the node group during the scale down phase, the upgrade workflow exits without restoring the group to its original size. Check your desired and max counts afterwards.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Treating insights as the only pre-flight check. It reads audit logs, so it cannot see code paths that have not run.</li>



<li>Reaching for <code>OVERWRITE</code> to make a failing add-on update succeed. It succeeds by discarding your configuration.</li>



<li>Upgrading add-ons and the control plane in one unbroken pipeline run, so you cannot tell which change broke things.</li>



<li>Leaving <code>maxUnavailable</code> at its default of one on a large node group, then wondering why the rollout takes hours.</li>



<li>Forgetting self-managed components. Cluster Autoscaler, the AWS Load Balancer Controller and metrics-server are versioned against Kubernetes too, and nothing reminds you.</li>
</ul>



<h2 class="wp-block-heading">How I sequence EKS version upgrades</h2>



<ol class="wp-block-list">
<li>Read the release notes for the target version. Not the summary post, the removals section.</li>



<li>Run all three deprecation checks: insights for what the cluster did, the API server metric for what it does now, a static scanner for what sits in the repo.</li>



<li>Audit PDBs and fix every zero. Highest-value ten minutes in the process.</li>



<li>Update add-ons with <code>PRESERVE</code> to versions compatible with both the current and target Kubernetes version, so components talking to the API server are ready before it moves.</li>



<li>Upgrade the control plane and stop. Watch error rates and API server latency long enough to mean something.</li>



<li>Roll the least critical node group. Confirm pods reschedule cleanly and nothing depended on a node label or an AMI-specific detail.</li>



<li>Roll the rest, confirm every kubelet matches the control plane, then treat the following week as the real test. That is what the rollback window is for.</li>
</ol>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">Can you do EKS version upgrades with zero downtime?</h3>



<p class="wp-block-paragraph">For stateless workloads with sensible replica counts, spread across zones, with budgets allowing at least one disruption, yes. The default node group strategy launches replacements before terminating anything, so capacity holds throughout. Stateful workloads can too, but only if the application handles rolling leadership changes and the budget permits exactly one disruption at a time.</p>



<h3 class="wp-block-heading">Why does the update fail with PodEvictionFailure when my PDBs look fine?</h3>



<p class="wp-block-paragraph">Two likely causes. Multiple budgets may select the same pod, in which case the most restrictive applies and neither manifest reveals it alone. Or something on the node tolerates every taint, so the node never becomes empty and the drain never completes. Check <code>ALLOWED DISRUPTIONS</code> across all namespaces, then check tolerations.</p>



<h3 class="wp-block-heading">Is the force update option safe?</h3>



<p class="wp-block-paragraph">Safe for stateless workloads with multiple replicas. Not safe for quorum-based systems, because it deletes pods regardless of the budget protecting quorum. Use it when you know what is on the node group and the answer is nothing that minds being killed.</p>



<h3 class="wp-block-heading">Does Karpenter make upgrades easier than managed node groups?</h3>



<p class="wp-block-paragraph">More automatic, which is not the same thing. Drift detection replaces outdated nodes without you running a command, but you inherit a different set of controls to get wrong: budgets evaluated in UTC, uncovered schedule gaps that mean unlimited disruption, and a grace period that overrides PDBs entirely. Neither is easier. They fail differently.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">EKS version upgrades do not fail at the moment you run the command. They fail on decisions already sitting in the cluster: a budget written for a two-replica deployment, an add-on customized with <code>kubectl</code> rather than through its configuration values, a chart pinned before an API was removed.</p>



<p class="wp-block-paragraph">Which means the pre-flight checks are the upgrade. Ten minutes reading <code>ALLOWED DISRUPTIONS</code> and the deprecated API metric tells you more about how tomorrow goes than any amount of care during the rollout. The rollback window gives you a second chance on the control plane. It will not un-delete a pod that force-eviction pulled out of a quorum.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need a second pair of eyes on your upgrade path?</h2>



<p class="wp-block-paragraph">Most of this work is unglamorous and specific. What I would actually do on a cluster facing a version bump:</p>



<ul class="wp-block-list">
<li>Audit every PDB against its replica count and flag the ones that will deadlock a drain.</li>



<li>Trace deprecated API calls back to the chart, controller or service account making them, from audit logs rather than guesswork.</li>



<li>Move add-on customizations into managed configuration values, so conflict resolution stops being a decision made under pressure.</li>



<li>Write a repeatable upgrade runbook: pre-flight checks, sequencing, and the rollback path for your specific workloads.</li>



<li>Alert on the deprecated API metric and node group update events, so the next version is a scheduled task rather than an incident.</li>



<li>Review Karpenter disruption budgets and grace periods for the gaps that turn into unplanned rollouts.</li>
</ul>



<p class="wp-block-paragraph">If you are staring at a failed node group update right now, send me the output of <code>kubectl get pdb --all-namespaces</code> and the error code from <code>describe-nodegroup</code>. That is usually enough to say what is holding the drain.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/eks-version-upgrades-without-downtime/">EKS Version Upgrades Without a Maintenance Window: APIs, Add-Ons and PDB Deadlocks</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Green Pipeline, Stale Data: Tracking Latency, Freshness and Failure Rates</title>
		<link>https://john-nessime.com/blog/devops/data-pipeline-freshness-monitoring/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 08 Sep 2026 06:00:00 +0000</pubDate>
				<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[Apache Airflow]]></category>
		<category><![CDATA[Data Freshness]]></category>
		<category><![CDATA[Data Observability]]></category>
		<category><![CDATA[Data Quality]]></category>
		<category><![CDATA[dbt]]></category>
		<category><![CDATA[Deadman Alerting]]></category>
		<category><![CDATA[ETL]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Kafka]]></category>
		<category><![CDATA[Pipeline Design]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[PromQL]]></category>
		<category><![CDATA[Pushgateway]]></category>
		<category><![CDATA[SLO]]></category>
		<category><![CDATA[SRE]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=407</guid>

					<description><![CDATA[<p>Every task green, zero errors, and the dashboard still shows yesterday's numbers. Run status describes your code, not your data. Here is how to instrument freshness, latency and failure rate so your pipeline tells you before an analyst does.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/data-pipeline-freshness-monitoring/">Green Pipeline, Stale Data: Tracking Latency, Freshness and Failure Rates</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The message lands at 8:40 in the morning. &#8220;Are the revenue numbers right? They look like yesterday&#8217;s.&#8221; You open the orchestrator and every task is green. The run finished in eleven minutes, comfortably inside its window. Zero errors. Zero retries. The pipeline did exactly what it was told to do, and the number on the dashboard is a day old.</p>



<p class="wp-block-paragraph">That gap is the entire problem. Run status tells you the code executed. It tells you nothing about whether data arrived. Data pipeline freshness monitoring is the signal that closes the gap, and most teams add it only after an analyst finds the stale table first.</p>



<p class="wp-block-paragraph">This post covers the three signals worth instrumenting for a batch or streaming pipeline: how current the data is, how long it took to get there, and how often the thing breaks. For each one I&#8217;ll cover what to measure, where to measure it, the queries and config that produce it, and the specific ways the measurement lies to you.</p>



<h2 class="wp-block-heading">The signal that lies to you first: run status</h2>



<p class="wp-block-paragraph">Job success is a statement about your code path, not about your data. A pipeline that reads an empty S3 prefix, transforms zero rows, writes zero rows and exits cleanly has succeeded. So has one whose upstream API silently started returning an empty page after a token rotation. So has one whose incremental watermark got stuck and now re-reads the same already-loaded slice on every run.</p>



<p class="wp-block-paragraph">All three are green. All three are producing stale data. This is why the ordering matters: freshness is the signal that fires first in a real incident, and run status is usually the last one to notice anything at all.</p>



<p class="wp-block-paragraph">So keep run status. It&#8217;s cheap and it catches crashes. Just stop treating it as your top-line health indicator.</p>



<h2 class="wp-block-heading">Freshness: measure the table, not the job</h2>



<p class="wp-block-paragraph">Freshness is the age of the newest record in a dataset. It&#8217;s measured against the dataset itself, not against the process that filled it, and that distinction is what makes it useful. If your job disappears entirely, freshness keeps climbing and keeps alerting. If your job succeeds while doing nothing, freshness keeps climbing and keeps alerting. It&#8217;s the one signal that survives both failure modes.</p>



<h3 class="wp-block-heading">Two clocks, and you need both</h3>



<p class="wp-block-paragraph">Every row usually carries two timestamps: when the event happened in the source system, and when your pipeline wrote it. Track the maximum of each.</p>



<ul class="wp-block-list">
<li><strong>Max event time</strong> tells you whether the upstream system is still producing. If this stops moving, the problem is upstream of you.</li>



<li><strong>Max load time</strong> tells you whether your pipeline is still writing. If this stops moving while event time is fine, the problem is yours.</li>
</ul>



<p class="wp-block-paragraph">Watching only one of them means every stale-data page starts with twenty minutes of figuring out which side of the boundary the fault sits on. Watching both answers that in the alert body.</p>



<p class="wp-block-paragraph">A freshness probe is a small scheduled query. This one is PostgreSQL syntax, and it returns both clocks plus a volume check in a single round trip:</p>



<pre class="wp-block-code"><code>select
  'orders' as dataset,
  extract(epoch from max(event_ts))  as max_event_ts,
  extract(epoch from max(loaded_at)) as max_loaded_at,
  count(*) filter (where loaded_at &gt;= now() - interval '1 hour') as rows_last_hour
from analytics.orders;</code></pre>



<p class="wp-block-paragraph">The row count matters. A pipeline can advance its load timestamp while writing almost nothing, which is what a partially broken source looks like. Freshness alone will not catch that; freshness plus volume will.</p>



<p class="wp-block-paragraph">Run that probe on a schedule that&#8217;s independent of the pipeline, push the results as gauges, and alert on age in PromQL:</p>



<pre class="wp-block-code"><code># Newest row is older than 90 minutes
time() - max by (dataset) (dataset_max_loaded_timestamp_seconds) &gt; 5400

# The probe itself has stopped reporting: a deadman check
absent(dataset_max_loaded_timestamp_seconds{dataset="orders"})</code></pre>



<p class="wp-block-paragraph">That second rule is the one people forget. A freshness metric that disappears looks identical to a healthy silence on a graph. <code>absent()</code> is what turns &#8220;no data&#8221; into a page.</p>



<h3 class="wp-block-heading">Where dbt fits</h3>



<p class="wp-block-paragraph">If you&#8217;re already running dbt, source freshness is built in and worth using before you write anything custom. You declare thresholds per source, and dbt queries the maximum of your timestamp column and compares it to now.</p>



<pre class="wp-block-code"><code>sources:
  - name: raw_shop
    schema: raw
    config:
      loaded_at_field: _loaded_at
      freshness:
        warn_after: {count: 2, period: hour}
        error_after: {count: 6, period: hour}
    tables:
      - name: orders
      - name: refunds
        config:
          freshness:
            warn_after: {count: 12, period: hour}
            error_after: {count: 24, period: hour}</code></pre>



<p class="wp-block-paragraph">Three details that trip people up. First, <code>dbt source freshness</code> is a separate command; <code>dbt build</code> does not run it, so a green build says nothing about source staleness. Second, it exits non-zero when a source hits its <code>error_after</code> threshold, which makes it a natural gate at the top of a job: fail fast rather than building models on stale input. Third, results land in <code>target/sources.json</code>, which is the artifact you parse if you want to distinguish warn from error, or ship the numbers into Prometheus rather than just failing the run.</p>



<pre class="wp-block-code"><code>dbt source freshness --select source:raw_shop --output target/freshness.json</code></pre>



<p class="wp-block-paragraph">One caveat on the YAML above: dbt moved these keys under a <code>config:</code> block in recent releases, and <code>loaded_at_field</code> followed later. Older projects nest them directly under the source. Check what your project&#8217;s version expects before copying, because a misplaced key fails quietly by simply not calculating freshness at all.</p>



<h2 class="wp-block-heading">Latency: name the clock before you name the number</h2>



<p class="wp-block-paragraph">&#8220;Our pipeline latency is twelve minutes&#8221; is meaningless until you say which two points you measured between. There are at least three plausible definitions, and teams routinely argue past each other because they&#8217;re each using a different one.</p>



<ul class="wp-block-list">
<li><strong>Run duration.</strong> Start to end of the job. Easy, and mostly useless for anyone downstream.</li>



<li><strong>Ingestion latency.</strong> Source extract to target load. This is what you control.</li>



<li><strong>End-to-end latency.</strong> Event time to the moment the row is queryable. This is what the business actually feels, and it includes queue wait, scheduler delay, and every upstream hop you don&#8217;t own.</li>
</ul>



<p class="wp-block-paragraph">Publish end-to-end as the headline and keep the others as breakdown. If you only track run duration, a scheduler backlog that delays every run by forty minutes is completely invisible to you: each individual run still takes eleven minutes.</p>



<p class="wp-block-paragraph">Instrument per stage with a histogram so you can ask percentile questions later without re-instrumenting:</p>



<pre class="wp-block-code"><code>histogram_quantile(
  0.95,
  sum by (le, stage) (rate(pipeline_stage_duration_seconds_bucket[6h]))
)</code></pre>



<p class="wp-block-paragraph">Use the median for capacity planning and p95 or p99 for the SLO. Averages hide the exact tail that generates the complaints, and on a pipeline that runs a few dozen times a day, a single pathological run is a real fraction of your day.</p>



<p class="wp-block-paragraph">For streaming, the equivalent is consumer lag expressed in time rather than offsets. Offset lag of fifty thousand messages means nothing without a rate; two hundred seconds of lag means something to everyone. Kafka Lag Exporter popularised this by interpolating a time estimate from observed offset and timestamp samples, exposing <code>kafka_consumergroup_group_max_lag_seconds</code> alongside the offset-based <code>kafka_consumergroup_group_lag</code>. Worth knowing: that project&#8217;s repository has been archived and is read-only, so if you&#8217;re starting fresh, check whether your broker vendor or a maintained fork covers it before you deploy something unmaintained into the critical path.</p>



<h2 class="wp-block-heading">Failure rate: decide what counts as a failure</h2>



<p class="wp-block-paragraph">Failure rate is trivially easy to compute and surprisingly easy to compute wrongly. The denominator and the definition both need a decision.</p>



<p class="wp-block-paragraph">Count runs, not tasks. A DAG with sixty tasks where one flaps on a transient network error looks catastrophic at task level and fine at run level. The run is the unit the consumer cares about.</p>



<p class="wp-block-paragraph">Count a run that succeeded on its third retry as a success for availability and a failure for a separate reliability metric. Both are true and they answer different questions. If your only metric folds retries into success, you will never see the slow degradation of an upstream API until it stops responding entirely.</p>



<pre class="wp-block-code"><code>sum by (pipeline) (rate(pipeline_runs_total{result="failure"}[6h]))
/
sum by (pipeline) (rate(pipeline_runs_total[6h]))</code></pre>



<p class="wp-block-paragraph">And add a category label for the failure reason at push time: source unavailable, schema mismatch, permission denied, timeout, validation failed. The rate tells you something is wrong. The category tells you who to wake up. Without it, every failure alert costs you a log dive before you can even route the incident.</p>



<h2 class="wp-block-heading">Getting metrics out of a job that exits</h2>



<p class="wp-block-paragraph">Prometheus scrapes. Batch jobs finish and vanish. Pushgateway bridges that: the job pushes before exiting, and Pushgateway holds the values for Prometheus to scrape on its own schedule.</p>



<pre class="wp-block-code"><code>#!/usr/bin/env bash
set -euo pipefail

JOB="orders_load"
PGW="http://pushgateway.internal:9091"

start=$(date +%s)
if python /opt/pipelines/load_orders.py; then
  result=0
else
  result=1
fi
end=$(date +%s)

{
  cat &lt;&lt;EOF
# TYPE pipeline_run_duration_seconds gauge
pipeline_run_duration_seconds $((end - start))
# TYPE pipeline_last_run_timestamp_seconds gauge
pipeline_last_run_timestamp_seconds $end
# TYPE pipeline_last_run_success gauge
pipeline_last_run_success $((1 - result))
EOF
  if [ "$result" -eq 0 ]; then
    cat &lt;&lt;EOF
# TYPE pipeline_last_success_timestamp_seconds gauge
pipeline_last_success_timestamp_seconds $end
EOF
  fi
} | curl --fail --data-binary @- "$PGW/metrics/job/$JOB"

exit $result</code></pre>



<p class="wp-block-paragraph">The conditional block is doing real work. curl&#8217;s <code>--data-binary</code> issues a POST, and a POST to Pushgateway replaces only the metrics whose names appear in the payload, leaving the rest of the group intact. So a failing run updates the run timestamp and the success flag while leaving the previous <code>pipeline_last_success_timestamp_seconds</code> exactly where it was. That&#8217;s what lets <code>time() - pipeline_last_success_timestamp_seconds</code> keep climbing across consecutive failures. Send a PUT instead and you replace the whole group, wiping the value you needed.</p>



<p class="wp-block-paragraph">On the Prometheus side, one setting is not optional:</p>



<pre class="wp-block-code"><code>scrape_configs:
  - job_name: pushgateway
    honor_labels: true
    static_configs:
      - targets: ['pushgateway.internal:9091']</code></pre>



<p class="wp-block-paragraph">Without <code>honor_labels: true</code>, Prometheus overwrites the <code>job</code> label your pipeline pushed with the scrape job&#8217;s own name, and every pipeline in your estate collapses into one indistinguishable series called <code>pushgateway</code>.</p>



<p class="wp-block-paragraph">The trap worth internalising: Pushgateway never forgets. Metrics persist until something explicitly deletes them or the process restarts. A pipeline you decommissioned last quarter is still cheerfully reporting a success timestamp and a duration, and it looks alive on every dashboard. Treat Pushgateway as a cache of past executions, not a picture of current state, and use the <code>push_time_seconds</code> gauge that Pushgateway attaches to each group to tell the difference between a fresh push and a fossil.</p>



<h3 class="wp-block-heading">If you&#8217;re on Airflow, the SLA feature is gone</h3>



<p class="wp-block-paragraph">This one will bite anyone upgrading. The <code>sla</code> and <code>sla_miss_callback</code> parameters were removed in Airflow 3.0, and the replacement, Deadline Alerts, arrived in 3.1. DAGs carrying the old configuration need manual migration; they don&#8217;t quietly keep working.</p>



<pre class="wp-block-code"><code>from datetime import timedelta

from airflow.sdk import AsyncCallback, DAG, DeadlineAlert, DeadlineReference
from airflow.providers.slack.notifications.slack_webhook import SlackWebhookNotifier

with DAG(
    dag_id="orders_load",
    deadline=DeadlineAlert(
        reference=DeadlineReference.DAGRUN_QUEUED_AT,
        interval=timedelta(minutes=45),
        callback=AsyncCallback(
            SlackWebhookNotifier,
            kwargs={"text": "orders_load has not finished 45 minutes after queuing."},
        ),
    ),
):
    ...</code></pre>



<p class="wp-block-paragraph">Note the reference point. Measuring from when the run was queued, rather than from its logical date, means scheduler backlog counts against the deadline. That&#8217;s usually what you want, because a run that sat in a queue for an hour is late to its consumers regardless of how fast it executed once it started.</p>



<p class="wp-block-paragraph">There&#8217;s a structural weakness here worth naming: any alert that lives inside the orchestrator dies with the orchestrator. If the scheduler is down, nothing evaluates your deadline and nothing notifies anyone. The freshness probe from earlier is the answer, and it needs to run somewhere else. A small VPS from a provider like Contabo or InterServer running Prometheus and Alertmanager, or a hosted option like Grafana Cloud, gives you a watcher outside the blast radius of the thing being watched. This is the single highest-value piece of monitoring most data teams are missing.</p>



<h2 class="wp-block-heading">Alerting without burning your on-call</h2>



<p class="wp-block-paragraph">The fastest way to make all of this worthless is to alert on every threshold crossing. Three rules keep it survivable.</p>



<ol class="wp-block-list">
<li><strong>Set thresholds from observed behaviour, not from wishes.</strong> Look at a month of actual freshness values and set <code>warn_after</code> a comfortable margin above the normal worst case. A source that habitually passes at eleven hours against a twelve-hour threshold is not healthy, it&#8217;s one upstream hiccup from paging you.</li>



<li><strong>Page on consumer impact, not internal events.</strong> A task retry is not an incident. A dataset breaching the freshness commitment its consumers rely on is. Route everything else to a channel someone reads in the morning.</li>



<li><strong>Use burn rate over multiple windows for SLOs.</strong> A short window catches fast breakage, a long window catches slow erosion, and requiring both to fire filters out the transient spikes that generate most false pages.</li>
</ol>



<p class="wp-block-paragraph">One more: alert on the absence of your own telemetry. A freshness gauge that stops updating is indistinguishable from one that&#8217;s fine, right up until someone asks about the numbers.</p>



<h2 class="wp-block-heading">Troubleshooting</h2>



<p class="wp-block-paragraph"><strong>Freshness alert fires but the data looks current.</strong> Almost always a timezone problem. Your <code>loaded_at</code> column is in local time, <code>now()</code> is in UTC, and the offset shows up as a constant bias in the age. Store load timestamps in UTC and cast explicitly at read time.</p>



<p class="wp-block-paragraph"><strong>Freshness flaps in and out of breach.</strong> Your check runs too close to the expected arrival. If loads land around six and your probe runs at five past, a fifteen-minute upstream delay produces an intermittent failure that trains everyone to ignore the alert. Move the probe later or widen the threshold.</p>



<p class="wp-block-paragraph"><strong>Every pipeline reports as one series.</strong> <code>honor_labels: true</code> is missing from the Pushgateway scrape config.</p>



<p class="wp-block-paragraph"><strong>A decommissioned pipeline still shows healthy.</strong> Stale group in Pushgateway. Delete the group and add a check on <code>push_time_seconds</code> so the next one surfaces on its own.</p>



<p class="wp-block-paragraph"><strong>Latency looks fine but consumers say data is late.</strong> You&#8217;re measuring run duration and they&#8217;re feeling end-to-end. Add queue wait and event-time-to-load and the gap will be obvious.</p>



<p class="wp-block-paragraph"><strong>Freshness passes, row count is near zero.</strong> The load timestamp advanced without meaningful data. This is a broken source or a stuck watermark, and it&#8217;s the reason the volume check belongs in the same probe.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Treating job success as the health signal and discovering staleness through a human.</li>



<li>Running the freshness check inside the same pipeline it&#8217;s meant to police.</li>



<li>Tracking freshness without volume, so a zero-row load reads as healthy.</li>



<li>Reporting latency without saying which two timestamps it spans.</li>



<li>Putting run IDs, batch IDs or timestamps into Prometheus labels, which multiplies your series count without bound. Those belong in traces or logs.</li>



<li>Alerting on task-level failures instead of run-level outcomes, then muting the whole channel a week later.</li>



<li>Assuming Pushgateway reflects current state rather than the last thing anyone pushed.</li>
</ul>



<h2 class="wp-block-heading">Best practices for data pipeline freshness monitoring</h2>



<ul class="wp-block-list">
<li>Start with your three most-used tables. Full coverage is a project; three tables is an afternoon and catches most of the pain.</li>



<li>Write down the freshness commitment per dataset in plain language, then encode it. &#8220;Yesterday&#8217;s orders are complete by 07:00&#8221; converts directly into a threshold.</li>



<li>Name metrics consistently across pipelines. A shared prefix and a stable label set is what makes one dashboard work for all of them.</li>



<li>Keep labels low-cardinality: pipeline, dataset, stage, environment, result. Nothing unbounded.</li>



<li>Emit a failure category alongside every failure so alerts route themselves.</li>



<li>Run the deadman check on infrastructure that doesn&#8217;t share a failure domain with the pipeline.</li>



<li>Link every alert to a runbook that names the owner and the first three things to check.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">What is the difference between pipeline latency and data freshness?</h3>



<p class="wp-block-paragraph">Latency measures how long a specific batch of data took to travel from source to destination. Freshness measures how old the newest available record is right now, regardless of whether anything is currently running. A pipeline can have excellent latency and terrible freshness if it stopped being triggered.</p>



<h3 class="wp-block-heading">How often should freshness checks run?</h3>



<p class="wp-block-paragraph">Frequently enough that you find out before your consumers do. A useful rule is roughly a quarter of your tolerance window: if data may be up to four hours old, check hourly. Freshness probes are cheap single-aggregate queries, so the limiting factor is usually warehouse billing rather than load.</p>



<h3 class="wp-block-heading">Do I need a data observability platform for this?</h3>



<p class="wp-block-paragraph">Not to start. Freshness, latency and failure rate for a handful of critical datasets is a scheduled query, a push, and a few alert rules. Commercial platforms earn their cost when you need automatic column-level lineage, anomaly detection across hundreds of tables, or coverage of assets nobody has explicitly instrumented. That&#8217;s a real problem at scale, and a genuinely expensive one to build yourself. It&#8217;s just not the problem you have on day one.</p>



<h3 class="wp-block-heading">How do I monitor freshness for a table that only updates weekly?</h3>



<p class="wp-block-paragraph">Set the threshold from the schedule plus a delivery margin, and add a deadman rule so a missing metric alerts on its own. For genuinely static reference tables, disable freshness explicitly rather than leaving a check that always warns; in dbt that means setting freshness to null for the table.</p>



<h3 class="wp-block-heading">Should freshness checks fail the pipeline or just warn?</h3>



<p class="wp-block-paragraph">Both, in different places. Checking source freshness at the start of a job and failing hard prevents you building models on stale input, which is the cheapest bug to prevent and the most expensive to unwind. Checking output freshness after the fact should alert rather than fail, because the run is already over.</p>



<h3 class="wp-block-heading">What percentile should I use for a latency SLO?</h3>



<p class="wp-block-paragraph">p95 for most internal analytics pipelines, p99 where downstream systems make automated decisions on the data. Track the median separately for capacity planning. Never use the average as the SLO number, because it hides exactly the tail that produces complaints.</p>



<h3 class="wp-block-heading">Does this work for streaming pipelines too?</h3>



<p class="wp-block-paragraph">Yes, with different plumbing. Freshness becomes consumer lag measured in time, latency becomes event-time to availability-time, and failure rate becomes connector and task state plus dead-letter volume. The reasoning is identical; only the source of the numbers changes.</p>



<h2 class="wp-block-heading">Conclusion</h2>



<p class="wp-block-paragraph">If you remember one thing, make it this: measure the data, not the job. Run status, task counts and duration all describe your code. Only freshness describes what your consumers actually receive, and it&#8217;s the one signal that stays honest when the pipeline succeeds at doing nothing.</p>



<p class="wp-block-paragraph">Good data pipeline freshness monitoring is not a platform purchase. It&#8217;s a scheduled query that reports the age and volume of your most important tables, pushed somewhere durable, with a deadman rule so that silence is treated as a failure rather than as health. Add latency broken down by stage and failure rate categorised by reason, and you can answer &#8220;is the data good right now&#8221; without opening a single log file.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need help instrumenting your pipelines?</h2>



<p class="wp-block-paragraph">Most of my consulting work in this area is retrofitting observability onto pipelines that already exist and can&#8217;t be paused. Specifically:</p>



<ul class="wp-block-list">
<li>Defining freshness and latency commitments per dataset, then translating them into thresholds and alert rules that hold up on-call.</li>



<li>Adding freshness and volume probes to existing warehouses without touching the pipelines themselves.</li>



<li>Wiring batch jobs into Prometheus through Pushgateway, including the grouping-key and stale-metric problems that bite six months later.</li>



<li>Migrating Airflow DAGs off the removed SLA feature onto Deadline Alerts, or onto external checks that survive a scheduler outage.</li>



<li>Building the Grafana dashboard that answers &#8220;is the data good right now&#8221; in one screen, with drill-down by stage and failure category.</li>



<li>Cutting alert noise on pipelines where the channel has already been muted, by moving from task-level events to consumer-impact SLOs.</li>
</ul>



<p class="wp-block-paragraph">If you want a concrete starting point, send me a DAG file, a scrape config, or a screenshot of the dashboard you don&#8217;t trust, and I&#8217;ll tell you what I&#8217;d instrument first and why.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/data-pipeline-freshness-monitoring/">Green Pipeline, Stale Data: Tracking Latency, Freshness and Failure Rates</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Grafana Alert Notifications That Actually Arrive: Email, Slack and Telegram</title>
		<link>https://john-nessime.com/blog/devops/grafana-alert-notifications-email-slack-telegram/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 27 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[Alertmanager]]></category>
		<category><![CDATA[Contact Points]]></category>
		<category><![CDATA[Email Deliverability]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Incident Response]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Notification Policies]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[Slack]]></category>
		<category><![CDATA[SMTP]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Telegram]]></category>
		<category><![CDATA[Webhooks]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=258</guid>

					<description><![CDATA[<p>The rule went red at 02:04 and nobody found out until 09:15. Rule evaluation and notification delivery are two different systems, and only one of them is visible in the UI. A practical setup for Grafana alert notifications to email, Slack and Telegram, plus how to detect the day delivery quietly stops.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/grafana-alert-notifications-email-slack-telegram/">Grafana Alert Notifications That Actually Arrive: Email, Slack and Telegram</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The disk filled at 02:00. The rule went red at 02:04. Nobody found out until 09:15, when someone opened the alert list, saw a Firing rule with a five-hour age on it, and asked the obvious question: why didn&#8217;t anyone get paged?</p>



<p class="wp-block-paragraph">The rule was fine. The query was fine. The evaluation ran on schedule and the state transition happened exactly when it should have. What failed was everything after that, and none of it made a sound.</p>



<p class="wp-block-paragraph">That is the failure mode worth designing around. Rule evaluation and notification delivery are two different systems, and a red rule only proves the first one works. Grafana alert notifications run against SMTP servers, Slack&#8217;s API and Telegram&#8217;s bot endpoint, all of which fail in their own quiet ways. A rejected login, a revoked webhook, a message over a length limit: each produces a log line and nothing else. The alert list still shows the rule firing, so everything looks healthy from the one place you&#8217;d think to check.</p>



<p class="wp-block-paragraph">This post covers the practical setup for the three channels most small and mid-sized teams actually use: email, Slack and Telegram. It covers what breaks in each of them, how to route alerts so the right ones interrupt people and the rest don&#8217;t, how to provision the whole thing as files instead of clicking through the UI, and how to detect the case where delivery has stopped entirely.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Where a firing alert actually goes</h2>



<p class="wp-block-paragraph">Before touching any integration, it helps to know how many hops sit between &#8220;the rule fired&#8221; and &#8220;my phone buzzed&#8221;.</p>



<ul class="wp-block-list">
<li>The rule evaluates on its own schedule and produces alert instances, each carrying labels.</li>
<li>Those instances go to an Alertmanager. Grafana ships its own and can also use an external Prometheus Alertmanager.</li>
<li>The Alertmanager applies mute timings, groups instances, and walks the notification policy tree for a match.</li>
<li>The matched policy names a contact point, which holds one or more integrations.</li>
<li>Each integration makes its own outbound call to a third-party service.</li>
</ul>



<p class="wp-block-paragraph">Steps one and two are visible in the Grafana UI. Steps three through five are where things go wrong, and they are visible only in logs. That asymmetry is the whole problem.</p>



<p class="wp-block-paragraph">One thing worth knowing early: a contact point can hold several integrations at once, so a single contact point named <code>critical</code> can fire email, Slack and Telegram together. Building one contact point per channel instead makes routing much harder than it needs to be.</p>



<h2 class="wp-block-heading">Email: the channel everyone assumes works</h2>



<p class="wp-block-paragraph">Email is the one people configure first and verify least. The catch is that the email contact point holds only the recipient addresses. The actual SMTP credentials live in <code>grafana.ini</code>, so a perfectly valid contact point can sit there doing nothing because the server underneath was never configured.</p>



<pre class="wp-block-code"><code>[smtp]
enabled = true
host = smtp.example.com:587
user = alerts@example.com
# Wrap the password in triple quotes if it contains # or ;
password = """your-smtp-password"""
from_address = alerts@example.com
from_name = Grafana Alerts
skip_verify = false
# Default is OpportunisticStartTLS
startTLS_policy = MandatoryStartTLS</code></pre>



<p class="wp-block-paragraph">Several details in that block matter more than they look.</p>



<ul class="wp-block-list">
<li><strong>The port is part of <code>host</code>.</strong> There is no separate port setting. Port 587 with STARTTLS is the normal choice; 465 is implicit TLS and behaves differently, which is usually why one fails cleanly and the other hangs.</li>
<li><strong><code>startTLS_policy</code> decides how hard Grafana insists on encryption.</strong> The default is opportunistic: it upgrades the connection if the server offers it and quietly continues in the clear if not. On a relay you control, mandatory turns a silent downgrade into a visible failure.</li>
<li><strong><code>from_address</code> has to be something the relay accepts.</strong> Managed relays reject mail from unverified domains <em>after</em> the connection succeeds, which is why the Grafana side looks fine.</li>
<li><strong>Leave <code>skip_verify</code> false.</strong> It exists for internal relays with self-signed certificates. Turning it on to clear an error removes the check that was telling you the certificate is wrong.</li>
</ul>



<p class="wp-block-paragraph">Changes to this file need a Grafana restart. Nothing in the UI reloads SMTP settings.</p>



<h3 class="wp-block-heading">Deliverability is a separate problem from delivery</h3>



<p class="wp-block-paragraph">Sending directly from a VPS is the fastest way to get alerts filed as spam. A fresh IP range from a provider like Contabo or InterServer has no sending reputation, and a mail server that emits one message a week during incidents never builds one.</p>



<p class="wp-block-paragraph">Point Grafana at a transactional relay instead. Amazon SES, Postmark, Mailgun and Brevo all do the same job at this volume. Whichever you pick, publish SPF and DKIM records for the sending domain and set a DMARC policy, then send a real alert to a Gmail address and a Microsoft 365 address and check the spam folder. That is the only test that tells you anything.</p>



<h2 class="wp-block-heading">Slack: webhook or bot token</h2>



<p class="wp-block-paragraph">Grafana supports two ways of posting to Slack, and the choice has consequences beyond setup time.</p>



<p class="wp-block-paragraph"><strong>Incoming webhooks</strong> are the quick path. Slack generates a URL under <code>hooks.slack.com/services/</code> that is bound to a single channel, and Grafana posts to it. There&#8217;s no OAuth flow and no scopes to reason about. The trade-off is that the URL <em>is</em> the credential and it maps to exactly one channel, so a team with six alert channels ends up managing six secrets with no central view of them.</p>



<p class="wp-block-paragraph"><strong>A Slack app with a bot token</strong> takes longer. You create the app, add the <code>chat:write.public</code> scope so it can post to public channels without being invited to each one, then copy the bot user OAuth token, which starts with <code>xoxb-</code>. In Grafana you supply that token plus the channel ID as the recipient. One credential covers every channel, permissions are visible in one place, and revoking it kills all posting at once.</p>



<p class="wp-block-paragraph">Webhooks win when you have one or two channels and want to be done in five minutes. The bot token wins as soon as channels start multiplying or someone asks what the integration is permitted to do. I reach for the webhook on small setups and regret it around the fourth channel.</p>



<p class="wp-block-paragraph">Two constraints either way. A Slack integration targets one channel, so several channels means several integrations. And notification templates control the message title and body text, not its visual structure, so rich block layouts aren&#8217;t on the table.</p>



<h2 class="wp-block-heading">Telegram: cheap paging, one nasty edge</h2>



<p class="wp-block-paragraph">Telegram is genuinely useful for out-of-hours alerts because it pushes to a phone without anyone paying for a paging product. The setup is two values: a bot API token and a chat ID.</p>



<ol class="wp-block-list">
<li>Message BotFather and send <code>/newbot</code>. The username has to end in <code>bot</code> or <code>_bot</code>.</li>
<li>Copy the HTTP API token it returns. That&#8217;s the bot API token Grafana wants.</li>
<li>Create a group for alerts and add the bot to it. A bot cannot message a chat it has never seen.</li>
<li>Send a slash command in the group, such as <code>/start@your_bot_name</code>. An explicit command is the reliable way to make the group visible to the bot.</li>
<li>Fetch the chat ID from the Telegram API.</li>
</ol>



<pre class="wp-block-code"><code>curl -s "https://api.telegram.org/bot&lt;YOUR_BOT_TOKEN&gt;/getUpdates" | jq '.result[].message.chat'</code></pre>



<p class="wp-block-paragraph">The <code>id</code> field in that output is what goes in Grafana&#8217;s Chat ID field. Group chat IDs are negative. Copy the minus sign; leaving it off is the single most common reason a Telegram contact point tests as failing.</p>



<p class="wp-block-paragraph">Now the edge case, and it&#8217;s the reason Telegram belongs in a post about invisible failures. Telegram caps messages at 4096 UTF-8 characters. If you&#8217;re using a parse mode other than none, truncating at that boundary can leave the markup unbalanced, and Telegram rejects the whole message as malformed. You don&#8217;t get a shortened alert. You get nothing.</p>



<p class="wp-block-paragraph">This is exactly backwards, because the message most likely to blow the limit is a large grouped alert during a real outage. The channel works perfectly for months of single alerts, then goes quiet the first time forty instances fire together. Three ways to deal with it, in the order I&#8217;d try them:</p>



<ul class="wp-block-list">
<li>Write a deliberately short notification template for Telegram: alert name, severity, instance count and a dashboard link. Treat it as a pager, not a report.</li>
<li>Narrow <code>group_by</code> on the policy that feeds Telegram so a single notification covers fewer instances.</li>
<li>Set the parse mode to none, which makes truncation harmless at the cost of formatting.</li>
</ul>



<h2 class="wp-block-heading">Routing: split by severity, not by tool</h2>



<p class="wp-block-paragraph">The most common design mistake is a policy tree organised around channels: a Slack policy, an email policy, a Telegram policy. That gives you no way to express the thing you actually care about, which is that some alerts should wake someone up and most should not.</p>



<p class="wp-block-paragraph">Put severity on the alert rule as a label and route on that instead. Contact points then become bundles of channels chosen to match urgency.</p>



<ul class="wp-block-list">
<li><code>severity=critical</code> goes to a contact point holding Telegram and Slack. Something is broken and someone needs to look now.</li>
<li><code>severity=warning</code> goes to Slack only, in a channel people read during working hours.</li>
<li>Everything else falls through to the default policy and lands in email, where it accumulates without interrupting anyone.</li>
</ul>



<p class="wp-block-paragraph">Four timing settings control how noisy this is, and each one does something specific:</p>



<ul class="wp-block-list">
<li><strong><code>group_by</code></strong> decides what counts as the same notification. Grouping by <code>alertname</code> and a service label means one cascading failure produces one message instead of forty.</li>
<li><strong><code>group_wait</code></strong> (default 30 seconds) is how long to hold the first notification for a new group, so related instances that fire a few seconds apart arrive together.</li>
<li><strong><code>group_interval</code></strong> (default 5 minutes) is the minimum gap before a group that has already notified sends again because new instances joined it.</li>
<li><strong><code>repeat_interval</code></strong> (default 4 hours) is how often an unresolved alert re-notifies with nothing new to say.</li>
</ul>



<p class="wp-block-paragraph">The temptation is to drop <code>repeat_interval</code> to fifteen minutes so nothing gets forgotten. Resist it. Teams that do this stop reading the channel within a month, which converts a routing problem into a much worse cultural one. Longer intervals plus a real escalation path beats short intervals every time.</p>



<p class="wp-block-paragraph">Grafana also offers a simpler path: pick a contact point directly on the alert rule form and skip label matching entirely. Reasonable when a rule has one obvious destination. The trade-off is that routing knowledge moves into individual rules, so changing where a class of alerts goes means editing every rule instead of one policy.</p>



<h2 class="wp-block-heading">Provision it as files, not clicks</h2>



<p class="wp-block-paragraph">Contact points built through the UI are invisible to review, easy to delete by accident, and impossible to recreate after a bad restore. If Grafana alert notifications matter enough to build, they matter enough to keep in Git. Grafana reads alerting configuration from files in the <code>provisioning/alerting</code> directory under its provisioning path at startup.</p>



<p class="wp-block-paragraph">Before writing any YAML by hand, build one contact point in the UI, test it, then use the export option on the contact points list to dump it as YAML. That gives you the exact setting keys for that integration type instead of the ones you half-remember from a blog post. Setting names are not consistent between integration types, and a guessed key produces a config that loads without error and sends nothing.</p>



<pre class="wp-block-code"><code>apiVersion: 1

contactPoints:
  - orgId: 1
    name: critical-oncall
    receivers:
      # Telegram receiver goes here. Export it from the UI first:
      # the setting key names differ from what you would guess.
      - uid: critical-slack
        type: slack
        settings:
          url: https://hooks.slack.com/services/REPLACE/WITH/YOURS
        disableResolveMessage: false

  - orgId: 1
    name: warnings-slack
    receivers:
      - uid: warnings-slack
        type: slack
        settings:
          url: https://hooks.slack.com/services/REPLACE/WITH/YOURS

  - orgId: 1
    name: email-digest
    receivers:
      - uid: email-digest
        type: email
        settings:
          addresses: ops@example.com
          singleEmail: false</code></pre>



<p class="wp-block-paragraph">And the policy tree that uses them:</p>



<pre class="wp-block-code"><code>apiVersion: 1

policies:
  - orgId: 1
    receiver: email-digest
    group_by: [grafana_folder, alertname]
    group_wait: 30s
    group_interval: 5m
    repeat_interval: 4h
    routes:
      - receiver: critical-oncall
        matchers:
          - severity = critical
        group_wait: 10s
        repeat_interval: 1h
      - receiver: warnings-slack
        matchers:
          - severity = warning
        repeat_interval: 12h</code></pre>



<p class="wp-block-paragraph">Two things to know about provisioned resources. By default they become read-only in the UI, which is the point but surprises people the first time. And a contact point that a policy references cannot be deleted until the policy stops referencing it, so removals have to happen in the right order.</p>



<p class="wp-block-paragraph">Keep tokens out of the files themselves. Grafana supports environment variable substitution in provisioning files, which means the YAML can live in Git and the secrets can come from your existing secret store.</p>



<h2 class="wp-block-heading">Detecting the silence</h2>



<p class="wp-block-paragraph">Everything above still leaves the original problem unsolved. If the Slack app gets revoked or the SMTP password rotates, notifications stop and Grafana does not tell you.</p>



<p class="wp-block-paragraph">The fix is a heartbeat alert, and it&#8217;s worth the twenty minutes. Create a rule whose condition is always true, give it a low severity label, route it to each contact point with a short <code>repeat_interval</code>, and point the resulting notification at an external dead-man&#8217;s-switch service. Healthchecks.io, Cronitor and Better Stack all offer this, and Healthchecks.io can be self-hosted if you&#8217;d rather not add a dependency. The service expects a signal on a schedule and alerts you when it stops arriving.</p>



<p class="wp-block-paragraph">The logic is inverted on purpose. You aren&#8217;t monitoring infrastructure here, you&#8217;re monitoring the path that tells you about your infrastructure, and it has to be watched by something that isn&#8217;t itself.</p>



<p class="wp-block-paragraph">As a second layer, Grafana exposes its own internal metrics on a Prometheus-compatible endpoint, including counters for notification attempts and failures. If you&#8217;re already scraping Grafana, alert on that failure counter increasing. It won&#8217;t catch a credential that works but delivers to a dead mailbox, which is why the heartbeat comes first.</p>



<h2 class="wp-block-heading">Troubleshooting: the rule fires, nothing arrives</h2>



<p class="wp-block-paragraph">Work down this list in order. Each step rules out one hop, and stopping early is how people spend an afternoon on the wrong layer.</p>



<ol class="wp-block-list">
<li><strong>Test the contact point in isolation.</strong> The Test button sends a notification without touching rules or policies. If it fails, the problem is credentials or network and nothing else matters yet.</li>
<li><strong>Watch the Grafana logs during the test.</strong> On a systemd install, run <code>journalctl -u grafana-server -f</code> while you click Test. SMTP, Slack and Telegram delivery errors all surface here, usually specific enough to fix directly.</li>
<li><strong>Compare the instance&#8217;s labels against your matchers.</strong> A policy matching <code>severity = critical</code> never sees <code>severity = Critical</code>. Matchers are exact and case-sensitive. Read the firing instance&#8217;s real labels, not the ones you think the rule sets.</li>
<li><strong>Look for a policy earlier in the tree that swallowed it.</strong> Evaluation stops at the first match unless the policy continues. A broad route near the top silently absorbs everything below it.</li>
<li><strong>Check active silences and mute timings.</strong> A silence created during last month&#8217;s maintenance with a generous matcher is the classic. So is a mute timing in the wrong timezone.</li>
<li><strong>Check whether you&#8217;re inside <code>repeat_interval</code>.</strong> A red alert now does not mean a notification is due now. This looks identical to a broken integration and isn&#8217;t.</li>
<li><strong>For email, read the relay&#8217;s own logs.</strong> Grafana handing the message off is not delivery. Bounces and spam filing happen after Grafana&#8217;s involvement ends.</li>
</ol>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li><strong>Configuring the email contact point and never touching <code>grafana.ini</code>.</strong> The contact point is only a recipient list. Without the SMTP block, it has nothing to send with.</li>
<li><strong>Routing by channel instead of severity.</strong> It feels tidy and it makes urgency impossible to express.</li>
<li><strong>Dropping the minus sign from a Telegram group chat ID.</strong> Group IDs are negative and the field takes the value literally.</li>
<li><strong>Sending verbose templates to Telegram.</strong> The 4096-character limit turns your biggest incident into your quietest one.</li>
<li><strong>Testing with the Test button and calling it done.</strong> Test bypasses the policy tree entirely. It proves the credential works, not that a real alert would ever reach it.</li>
<li><strong>Leaving resolved notifications on for every channel.</strong> Resolution messages in a pager channel double the volume and train people to ignore it.</li>
<li><strong>Storing webhook URLs and bot tokens in the UI only.</strong> They exist in one place, are invisible to review, and disappear with the database.</li>
</ul>



<h2 class="wp-block-heading">Best practices for Grafana alert notifications</h2>



<ul class="wp-block-list">
<li>Set a consistent <code>severity</code> label on every rule from day one. Retrofitting labels across dozens of rules is miserable work.</li>
<li>Use one contact point per urgency level, holding as many integrations as that level needs.</li>
<li>Put a dashboard or runbook link in an annotation your templates render. An alert that says what broke but not where to look costs the responder five minutes every time.</li>
<li>Write channel-specific templates: short for Telegram, richer for email, in between for Slack.</li>
<li>Provision contact points and policies from files, with secrets injected from the environment, and run a heartbeat through every channel.</li>
<li>Re-test every integration after credential rotation, Slack workspace changes or a Grafana upgrade. Those are the three events that quietly break delivery.</li>
</ul>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">Can one contact point send to email, Slack and Telegram at the same time?</h3>



<p class="wp-block-paragraph">Yes. A contact point holds multiple integrations and every one of them fires when that contact point is selected. This is the right way to build an escalation tier: one contact point per severity, with the channel mix chosen to match how urgently that severity needs attention.</p>



<h3 class="wp-block-heading">Why does the Test button work but real alerts never arrive?</h3>



<p class="wp-block-paragraph">Test sends straight to the contact point and skips the policy tree. If it works and real alerts don&#8217;t, the break is in routing: matchers that don&#8217;t match, a broader policy earlier in the tree absorbing the alert, an active silence, or a mute timing. Open the firing instance, read its real labels, and compare them character by character against your matchers.</p>



<h3 class="wp-block-heading">Do I need Grafana Cloud for any of this?</h3>



<p class="wp-block-paragraph">No. Contact points, notification policies, templates and all three integrations ship in the open source build. Grafana Cloud handles hosting and adds on-call tooling on top, which matters if you need rotations and escalation chains. For a team routing alerts to three channels, a self-hosted instance on a modest VPS does everything described here.</p>



<h3 class="wp-block-heading">Should I use a Slack webhook or a bot token?</h3>



<p class="wp-block-paragraph">Webhook for one or two channels where speed of setup matters. Bot token once you have several channels, want one revocable credential, or need to see in one place what the integration is allowed to do. Both deliver identically; the difference is entirely in credential management.</p>



<h3 class="wp-block-heading">Why did my Telegram alerts stop during a big outage?</h3>



<p class="wp-block-paragraph">Almost certainly the 4096-character limit. A large grouped notification exceeded it, truncation broke the message markup, and Telegram rejected the send outright. Shorten the template for that channel, group more narrowly, or drop the parse mode. It&#8217;s the failure mode most worth guarding against, because it only shows up when you need the channel most.</p>



<h3 class="wp-block-heading">Can I keep using the Prometheus Alertmanager I already run?</h3>



<p class="wp-block-paragraph">Yes. Grafana can send to an external Alertmanager instead of its built-in one, and the contact point selector at the top of the alerting pages lets you choose which one you&#8217;re configuring. If you already have a mature Alertmanager routing config, keeping it and pointing Grafana at it is usually less work than migrating the tree.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing to remember</h2>



<p class="wp-block-paragraph">A firing rule proves evaluation works. It proves nothing about whether anyone was told. Grafana alert notifications cross three or four systems after the rule turns red, and every one of them fails quietly by default.</p>



<p class="wp-block-paragraph">So build the channels carefully, route by severity rather than by tool, then run a heartbeat through every path and let something outside Grafana watch it. The alternative is finding out during the incident, which is the one time you have no attention to spare.</p>



<h2 class="wp-block-heading">Need alerting that reaches people?</h2>



<p class="wp-block-paragraph">Most of the Grafana setups I get asked to look at have good dashboards and alerting nobody trusts. Work I take on here:</p>



<ul class="wp-block-list">
<li>Fixing email delivery end to end: SMTP config, relay selection, SPF, DKIM and DMARC so alerts stop landing in spam.</li>
<li>Setting up Slack and Telegram contact points properly, including bot apps, scoped tokens and channel-appropriate templates.</li>
<li>Designing a policy tree around severity labels, with grouping and timing tuned so people keep reading the channel.</li>
<li>Converting click-built alerting into provisioned YAML under version control, with secrets kept out of Git.</li>
<li>Adding heartbeat monitoring so a broken notification path announces itself instead of waiting for an outage.</li>
<li>Auditing alert rules for missing labels, missing runbook links, and rules that fire constantly and are therefore ignored.</li>
</ul>



<p class="wp-block-paragraph">Send me your notification policy export and the output of <code>journalctl -u grafana-server</code> from a period when an alert should have gone out, and I&#8217;ll tell you which hop is dropping it.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/grafana-alert-notifications-email-slack-telegram/">Grafana Alert Notifications That Actually Arrive: Email, Slack and Telegram</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Monitoring n8n Workflow Failures in Grafana: Why Your Green Dashboard Is Lying</title>
		<link>https://john-nessime.com/blog/devops/monitoring-n8n-workflow-failures-grafana/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 25 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[Automation]]></category>
		<category><![CDATA[Cardinality]]></category>
		<category><![CDATA[Docker Compose]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[n8n]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[PromQL]]></category>
		<category><![CDATA[Pushgateway]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Webhooks]]></category>
		<category><![CDATA[Workflow Automation]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=276</guid>

					<description><![CDATA[<p>n8n's Prometheus endpoint tells you the process is alive and busy. It does not tell you a workflow stopped producing correct output. This post walks through the three failure families that slip past a default n8n dashboard, the PromQL that catches each one, and how to close the gap with a second source of truth from the executions API.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/monitoring-n8n-workflow-failures-grafana/">Monitoring n8n Workflow Failures in Grafana: Why Your Green Dashboard Is Lying</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Someone asks why the nightly invoice sync hasn&#8217;t posted anything since Tuesday. You open Grafana. The n8n scrape target is UP, the instance has been running for weeks without a restart, every panel is green. Nothing on the dashboard says a thing is wrong, and yet four days of records are missing.</p>



<p class="wp-block-paragraph">That gap is the entire problem. Monitoring n8n workflow failures in Grafana is not hard because the tooling is missing. It&#8217;s hard because the built-in metrics endpoint answers a question you didn&#8217;t ask. It tells you the process is alive and busy. It does not tell you that a workflow stopped producing correct output.</p>



<p class="wp-block-paragraph">This post covers what n8n&#8217;s Prometheus endpoint actually exposes, the three families of failure it handles badly, and the specific PromQL and alert rules that close each gap. There&#8217;s also a troubleshooting section for the case where <code>/metrics</code> returns nothing at all, which is the first wall most people hit.</p>



<h2 class="wp-block-heading">What the n8n metrics endpoint actually gives you</h2>



<p class="wp-block-paragraph">n8n uses the <code>prom-client</code> library and serves metrics on the same port as the editor, at <code>/metrics</code>. The endpoint is off by default, and almost everything interesting inside it is off by default too, behind its own environment variable.</p>



<pre class="wp-block-code"><code>N8N_METRICS=true
N8N_METRICS_INCLUDE_MESSAGE_EVENT_BUS_METRICS=true
N8N_METRICS_INCLUDE_WORKFLOW_ID_LABEL=true
N8N_METRICS_INCLUDE_NODE_TYPE_LABEL=true
N8N_METRICS_INCLUDE_QUEUE_METRICS=true</code></pre>



<p class="wp-block-paragraph">The first line opens the endpoint. The second turns on the workflow and node event counters, which is the part people assume is included and is not. The third attaches a <code>workflow_id</code> label to workflow metrics, without which you get a single instance-wide number and no way to tell which automation broke.</p>



<p class="wp-block-paragraph">Those labels are opt-in for a reason worth understanding rather than working around. Every distinct label value creates a separate time series. Turn on <code>workflow_id</code> and you get one series per workflow per counter; add <code>node_type</code> and node counters fan out across every integration you use. On a few dozen workflows that&#8217;s fine. On several hundred with per-node labels, you&#8217;re signing up for a cardinality problem that surfaces later as slow queries and a Prometheus process that keeps getting OOM-killed.</p>



<p class="wp-block-paragraph">With the event bus metrics enabled, the counters you get include <code>n8n_workflow_started_total</code>, <code>n8n_workflow_success_total</code>, <code>n8n_node_started_total</code> and <code>n8n_node_finished_total</code>. Queue mode adds <code>n8n_scaling_mode_queue_jobs_active</code>, <code>_completed</code>, <code>_failed</code> and <code>_waiting</code>, gathered from Bull and exposed on the main instance only.</p>



<p class="wp-block-paragraph">Prometheus side, the scrape job is unremarkable:</p>



<pre class="wp-block-code"><code>scrape_configs:
  - job_name: n8n
    static_configs:
      - targets:
          - n8n-main:5678
          - n8n-worker-1:5678
    metrics_path: /metrics</code></pre>



<p class="wp-block-paragraph">Note both main and workers. Each process exposes its own endpoint and its own counters. Scrape only the main and you lose everything the workers did, which in queue mode is nearly all the real execution work.</p>



<p class="wp-block-paragraph">One thing the n8n docs are emphatic about, and they&#8217;re right: don&#8217;t put <code>/metrics</code> on the public internet. It leaks workflow IDs, execution volumes and instance topology to anyone who asks. Keep the scrape path on a private network segment, a WireGuard tunnel, or your provider&#8217;s internal networking. If you&#8217;re running the whole stack on a single VPS from somewhere like Contabo or InterServer, binding n8n to the Docker network and scraping it by container name is enough. Publishing port 5678 to the host and firewalling it afterwards is not, because you will forget.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Failure family one: the execution that never happened</h2>



<p class="wp-block-paragraph">This is the one that costs the most and shows up the least. A schedule trigger gets deactivated during a deploy. A credential expires and the workflow is switched off rather than fixed. An upstream service stops calling a webhook. Nothing errors. Nothing increments. The workflow simply stops existing as far as your metrics are concerned.</p>



<p class="wp-block-paragraph">Every error-rate alert you write is blind to this, because an error rate needs a denominator. Zero executions divided by zero executions is not an alert, it&#8217;s a NaN.</p>



<p class="wp-block-paragraph">The instinct is to write this:</p>



<pre class="wp-block-code"><code>sum by (workflow_id) (increase(n8n_workflow_started_total[6h])) == 0</code></pre>



<p class="wp-block-paragraph">It looks correct and it does not work. When a workflow stops running, its series stops being exported. Prometheus marks it stale after a few missed scrapes and instant queries stop returning it. There is no series left for <code>== 0</code> to match against. The alert never fires, because the thing you wanted to alert on is exactly the thing that removed the data.</p>



<p class="wp-block-paragraph">What you want is a function that returns something when a series is absent:</p>



<pre class="wp-block-code"><code>absent_over_time(
  n8n_workflow_started_total{workflow_id="AbC123XyZ"}[6h]
)</code></pre>



<p class="wp-block-paragraph"><code>absent_over_time</code> returns 1 if the matched series produced no samples in the window and nothing at all if it did. That inversion is the point. The alert fires on silence.</p>



<p class="wp-block-paragraph">The cost is that <code>absent_over_time</code> needs an exact matcher, so you can&#8217;t write it once for everything. That&#8217;s more acceptable than it sounds. You don&#8217;t need a heartbeat on all two hundred workflows, you need one on the six a human would notice within an hour. Write those six by hand and treat the list as part of the runbook.</p>



<p class="wp-block-paragraph">One Grafana-specific trap. When a query returns nothing, Grafana&#8217;s alert rule enters a <strong>No Data</strong> state, which is configurable separately from the alert condition and defaults to firing in most versions. Set it deliberately. For an <code>absent_over_time</code> rule, No Data means the whole scrape target is gone, which is a different incident from one workflow going quiet, and you probably want it routed differently.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Failure family two: the failure you can see but can&#8217;t attribute</h2>



<p class="wp-block-paragraph">You have the counters. Something is failing. Which workflow?</p>



<p class="wp-block-paragraph">Without <code>N8N_METRICS_INCLUDE_WORKFLOW_ID_LABEL</code>, the answer is: no idea. The counters are instance-wide totals. You know the number moved and that&#8217;s it. That&#8217;s a fine signal for capacity planning and useless during an incident.</p>



<p class="wp-block-paragraph">Turn the label on and you get IDs, which are opaque strings like <code>AbC123XyZ</code>. A dashboard full of those is technically attributable and practically unreadable at 3am. Newer n8n versions solve this with a gauge that maps IDs to names, enabled with <code>N8N_METRICS_INCLUDE_WORKFLOW_INFO</code>:</p>



<pre class="wp-block-code"><code>n8n_workflow_info{workflow_id="VaQPuPmx9tPpo6BX",workflow_name="Invoice sync"} 1</code></pre>



<p class="wp-block-paragraph">It&#8217;s a constant-1 gauge that exists only to be joined against. The pattern is a standard Prometheus vector match:</p>



<pre class="wp-block-code"><code>sum by (workflow_name) (
  rate(n8n_workflow_started_total[5m])
  * on(workflow_id) group_left(workflow_name)
  n8n_workflow_info
)</code></pre>



<p class="wp-block-paragraph">The multiplication is arithmetically a no-op because the gauge is always 1. Its only job is to carry the <code>workflow_name</code> label across with <code>group_left</code>. Do this once in a Grafana variable or a recording rule rather than in every panel, or you&#8217;ll be copy-pasting the join for the rest of your life.</p>



<p class="wp-block-paragraph">On an older build without the info gauge, the fallback is a hand-maintained relabel config or a Grafana value mapping. Both drift, and the relabel version drifts silently.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Failure family three: the failure that counts as a success</h2>



<p class="wp-block-paragraph">This is the invisible one, and it&#8217;s why a green n8n dashboard is not evidence of anything.</p>



<p class="wp-block-paragraph">n8n gives you several deliberate ways to make a node&#8217;s failure not be an execution failure. They&#8217;re all reasonable features. Together they mean the success counter and &#8220;the automation worked&#8221; are different statements.</p>



<ul class="wp-block-list">
<li><strong>Continue on error.</strong> A node set to continue passes its error downstream instead of stopping the run. The execution finishes. It finishes as a success.</li>

<li><strong>Error output branches.</strong> A node with a separate error output routes failures to a different path. That path might log and stop. The execution still completes normally.</li>

<li><strong>Never Error on HTTP responses.</strong> The HTTP Request node can be configured to treat any status code as a valid response. A run of 500s from an upstream API becomes a workflow that processed zero useful records, successfully.</li>

<li><strong>Empty result sets.</strong> A query that returns nothing is not an error. A sync that syncs nothing, every night, looks exactly like a sync that has nothing to do.</li>
</ul>



<p class="wp-block-paragraph">There are also user reports on the n8n community forum of started and success counters matching even when a node failed outright, in queue mode. I have not reproduced that and I&#8217;d treat it as unconfirmed rather than a known behavior. But it&#8217;s the right instinct: don&#8217;t assume the success counter is a strict complement of failure. Verify it on your own instance before you build alerting on the assumption.</p>



<p class="wp-block-paragraph">For webhook-triggered workflows, newer n8n versions give you a real answer here. The <code>n8n_webhook_request_duration_seconds</code> histogram carries a <code>status_code</code> label, so you can alert on what the caller actually received rather than on what n8n thought about the run:</p>



<pre class="wp-block-code"><code>sum by (workflow_id) (
  rate(n8n_webhook_request_duration_seconds_count{status_code!~"2.."}[5m])
)</code></pre>



<p class="wp-block-paragraph">Worth flagging: the n8n documentation shows this example with <code>status_code!="2.."</code>, using the equality operator. In PromQL, <code>!=</code> compares against a literal string, so that matcher excludes only a status code whose literal value is the three characters <code>2..</code>, which never occurs. It matches everything, including your 200s. Use <code>!~</code> for the regex form. Test any label matcher you copy from documentation by running it in Prometheus and checking the series count against what you expect.</p>



<h3 class="wp-block-heading">Getting a second source of truth</h3>



<p class="wp-block-paragraph">For everything that isn&#8217;t a webhook, the metrics endpoint alone won&#8217;t close this gap. You need a second signal that reads execution outcomes from n8n&#8217;s own store.</p>



<p class="wp-block-paragraph">The public REST API exposes exactly that. Executions can be filtered by status, and the failed ones are one request away:</p>



<pre class="wp-block-code"><code>curl -s -H "X-N8N-API-KEY: $N8N_API_KEY" 
  "https://n8n.example.com/api/v1/executions?status=error&amp;limit=50"</code></pre>



<p class="wp-block-paragraph">Two ways to get that into Grafana. Write a small exporter that polls the API on a timer and serves its own <code>/metrics</code>, or run a scheduled job that pushes counts to a Prometheus Pushgateway. The exporter is more work and behaves correctly. Pushgateway is faster to build and carries the usual caveat: pushed metrics persist until deleted, so a job that stops running leaves a stale value sitting there looking healthy. That reintroduces failure family one through the back door.</p>



<p class="wp-block-paragraph">Two caveats on the API. There&#8217;s an open report that the <code>status=crashed</code> list filter returns executions whose stored status is something else entirely when re-fetched by ID, which would make crash alerting built on the list endpoint unreliable. Check the behavior on your version before depending on it. And n8n&#8217;s log streaming feature, which forwards execution events to syslog or a webhook in real time, is an Enterprise capability. If you&#8217;re on the community edition, the API poll is your path.</p>



<p class="wp-block-paragraph">The third option, and often the best one for a small setup, is an Error Trigger workflow. n8n lets you designate a workflow that runs whenever another workflow fails. Point it at Pushgateway, at Loki, or at a Grafana webhook contact point. It&#8217;s native, it needs no API key, and it gives you the error message rather than just a count. The trade-off is that it only fires on real execution failures, so it inherits every blind spot in this section.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The Grafana panels that earn their space</h2>



<p class="wp-block-paragraph">Four panels cover most of what you need. Everything past that is usually decoration.</p>



<ol class="wp-block-list">
<li><strong>Executions per workflow, by name.</strong> A time series of <code>rate(n8n_workflow_started_total[5m])</code> joined to <code>n8n_workflow_info</code>. Its real job is showing you the lines that flatten to zero.</li>

<li><strong>Success ratio.</strong> <code>rate(n8n_workflow_success_total[15m]) / rate(n8n_workflow_started_total[15m])</code>. Anything below 1 is worth a look. Anything above 1 means your window is catching a restart and the counters reset.</li>

<li><strong>Queue depth and failed jobs.</strong> <code>n8n_scaling_mode_queue_jobs_waiting</code> next to <code>increase(n8n_scaling_mode_queue_jobs_failed[1h])</code>. A waiting count that climbs and never drains means workers are dead or starved, which looks identical to &#8220;no failures&#8221; on every other panel.</li>

<li><strong>Webhook non-2xx rate.</strong> The query from the previous section. This is the only panel that reports what the outside world experienced.</li>
</ol>



<p class="wp-block-paragraph">Skip the node-level breakdown unless you have a specific reason. It is the most expensive thing you can enable in cardinality terms and it answers a question you can answer faster by opening the execution in the n8n UI.</p>



<h3 class="wp-block-heading">Alert rules worth paging on</h3>



<ol class="wp-block-list">
<li><strong>Named workflow silent.</strong> <code>absent_over_time</code> on each business-critical workflow, window set to roughly twice its expected interval. This is the rule that catches the invoice sync.</li>

<li><strong>Success ratio below threshold.</strong> Sustained for 15 minutes, not instantaneous. A single failed run is not a page.</li>

<li><strong>Queue waiting count rising.</strong> Compare the current value to fifteen minutes ago rather than to a fixed number, so the threshold survives traffic growth.</li>

<li><strong>Webhook error rate.</strong> Per workflow, so a single noisy endpoint doesn&#8217;t mask the rest.</li>

<li><strong>Scrape target down.</strong> The <code>up</code> metric. Obvious, and routinely forgotten, and it invalidates every other rule on this list.</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Troubleshooting: the endpoint returns nothing useful</h2>



<ul class="wp-block-list">
<li><strong>&#8220;Cannot GET /metrics&#8221;.</strong> <code>N8N_METRICS</code> isn&#8217;t set on the process you&#8217;re hitting, or it&#8217;s set as a string that n8n doesn&#8217;t read as true. In Docker Compose, quote it as <code>"true"</code> and confirm with <code>docker compose exec n8n env | grep N8N_METRICS</code>.</li>

<li><strong>Endpoint responds but has no n8n_ counters.</strong> You have the default Node.js process metrics and nothing else. Set <code>N8N_METRICS_INCLUDE_MESSAGE_EVENT_BUS_METRICS=true</code> and restart.</li>

<li><strong>Counters exist but no workflow_id label.</strong> <code>N8N_METRICS_INCLUDE_WORKFLOW_ID_LABEL</code> is off. Restart is required; it&#8217;s read at startup.</li>

<li><strong>Queue metrics are zero or missing.</strong> They&#8217;re gathered from Bull and exposed on the main instance, not the workers. Querying the worker endpoint gives you nothing. Confirm you&#8217;re looking at the right target.</li>

<li><strong>Counters look wrong after a deploy.</strong> They reset to zero when the process restarts. Always wrap them in <code>rate()</code> or <code>increase()</code>, which handle counter resets. Raw counter values on a dashboard will lie to you every time you deploy.</li>

<li><strong>Prometheus can&#8217;t reach the target.</strong> Usually a Docker network mismatch. Scrape by container name on the shared network rather than by published host port, and check the target page in Prometheus for the actual error rather than guessing.</li>
</ul>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Treating the success counter as proof the automation worked. It proves the run finished.</li>

<li>Building only error-rate alerts, so the workflows that stop entirely go unmonitored.</li>

<li>Enabling every <code>N8N_METRICS_INCLUDE_*</code> variable on day one and discovering the cardinality bill a month later.</li>

<li>Scraping only the main instance in queue mode and losing all worker-side execution data.</li>

<li>Copying label matchers out of documentation without running them and checking the returned series count.</li>

<li>Exposing <code>/metrics</code> publicly because it&#8217;s &#8220;just numbers&#8221;.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Decide which workflows are business-critical and write a silence alert for each one by name. Six good heartbeats beat two hundred generic panels.</li>

<li>Run a second source of truth. Metrics tell you about volume, the executions API tells you about outcomes. You need both.</li>

<li>Push the ID-to-name join into a recording rule so panels stay readable and cheap.</li>

<li>Break a staging workflow on purpose and confirm the alert fires. An untested alert is a decoration.</li>

<li>If you&#8217;d rather not run Prometheus and Grafana yourself, Grafana Cloud will scrape a remote endpoint and gives you alerting without a control plane to maintain. Weigh that against your metrics leaving your network.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">Does n8n expose a failed-executions counter?</h3>



<p class="wp-block-paragraph">Not a reliable per-workflow one. You get started and success counters from the event bus, and a failed-jobs counter for queue mode. Deriving failures by subtracting success from started works only if you&#8217;ve verified on your own instance that a failing run genuinely doesn&#8217;t increment success, which is exactly the assumption that breaks when nodes are set to continue on error.</p>



<h3 class="wp-block-heading">Can I monitor n8n Cloud this way?</h3>



<p class="wp-block-paragraph">No. The <code>/metrics</code> endpoint isn&#8217;t available on n8n Cloud. On Cloud you&#8217;re limited to the public API and the built-in insights views, so the executions-API approach in this post is the only one that transfers.</p>



<h3 class="wp-block-heading">Why does my workflow error rate look like zero when I know things are failing?</h3>



<p class="wp-block-paragraph">Almost always one of three things: the event bus metrics aren&#8217;t enabled so you&#8217;re only seeing Node.js process metrics, the failures are being absorbed by continue-on-error settings and recorded as successes, or you&#8217;re scraping the main instance while the work happens on workers.</p>



<h3 class="wp-block-heading">How do I alert when a scheduled workflow stops running entirely?</h3>



<p class="wp-block-paragraph">Use <code>absent_over_time</code> against that workflow&#8217;s started counter with an exact <code>workflow_id</code> matcher, and set the window to about twice the expected interval. A threshold comparison won&#8217;t work, because a workflow that stops running stops producing the series you&#8217;d compare against.</p>



<h3 class="wp-block-heading">Is enabling the workflow_id label safe on a large instance?</h3>



<p class="wp-block-paragraph">It multiplies your n8n series count by the number of workflows. On a few dozen it&#8217;s negligible. Past a few hundred, combined with node-type labels, watch your Prometheus memory and head series count before and after. Enable the workflow label, leave the node label off, and only turn the node one on when you have a specific question it answers.</p>



<h3 class="wp-block-heading">Should I use OpenTelemetry instead of Prometheus for n8n?</h3>



<p class="wp-block-paragraph">They solve different problems. Metrics answer &#8220;how many, how often, how fast&#8221; cheaply and forever. Traces answer &#8220;where did this run spend its time and what did it call&#8221;, which is better for debugging a slow multi-service workflow. Tracing n8n usually means a custom image with the OpenTelemetry SDK loaded before startup, so it&#8217;s a project rather than an environment variable. Start with metrics; add tracing when you have a latency question metrics can&#8217;t answer.</p>



<h3 class="wp-block-heading">Where do the n8n counters go when the container restarts?</h3>



<p class="wp-block-paragraph">They reset to zero. They&#8217;re in-process counters with no persistence. Prometheus handles this correctly as long as your queries use <code>rate()</code> or <code>increase()</code>. Any panel showing a raw counter value will appear to lose all its history on every deploy.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing to take away</h2>



<p class="wp-block-paragraph">Monitoring n8n workflow failures in Grafana works, but only once you stop asking the metrics endpoint a question it can&#8217;t answer. It reports activity. Activity and correctness are different things, and the space between them is where the expensive outages live.</p>



<p class="wp-block-paragraph">So build for absence first. A silence alert on the handful of workflows a human would miss will catch more real incidents than any error-rate panel, because errors are loud and stopped schedules are not. Then add attribution with the workflow label, then close the false-success gap with the executions API or an Error Trigger workflow. In that order, because each one is cheap and the value drops off fast after the third.</p>



<h2 class="wp-block-heading">Need help wiring this up?</h2>



<p class="wp-block-paragraph">I build and maintain observability stacks for self-hosted automation platforms, and n8n is a common one. If any of the above sounds like a week you&#8217;d rather not spend, here&#8217;s what I can take off your plate:</p>



<ul class="wp-block-list">
<li>Enabling and securing the n8n metrics endpoint on a private network path, including queue-mode setups with multiple workers</li>

<li>Building Prometheus scrape configs, recording rules and the ID-to-name join so your Grafana panels are readable rather than a wall of hashes</li>

<li>Writing silence and error-rate alert rules that survive counter resets, Grafana No Data states and traffic growth</li>

<li>Adding a second source of truth from the n8n executions API, either as a small exporter or an Error Trigger workflow feeding Loki or Pushgateway</li>

<li>Auditing an existing setup for cardinality problems, stale Pushgateway series and alerts that quietly stopped being able to fire</li>

<li>Running a failure drill against staging so you know which alerts actually work before you need them</li>
</ul>



<p class="wp-block-paragraph">Send me your Compose file, your <code>prometheus.yml</code>, or a screenshot of the panel that looks fine while things are on fire, and I&#8217;ll tell you what&#8217;s missing.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/monitoring-n8n-workflow-failures-grafana/">Monitoring n8n Workflow Failures in Grafana: Why Your Green Dashboard Is Lying</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Troubleshooting High I/O Wait Without Blaming the Disk</title>
		<link>https://john-nessime.com/blog/devops/troubleshooting-high-io-wait-linux/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 22 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[bpftrace]]></category>
		<category><![CDATA[cgroups]]></category>
		<category><![CDATA[Containers]]></category>
		<category><![CDATA[Disk I/O]]></category>
		<category><![CDATA[eBPF]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[iostat]]></category>
		<category><![CDATA[Kernel Tuning]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Performance Tuning]]></category>
		<category><![CDATA[Pressure Stall Information]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Storage]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[VPS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=248</guid>

					<description><![CDATA[<p>High I/O wait is a symptom, not a diagnosis. It rises when nothing is wrong and stays flat when something is badly wrong, and it only sees one layer of the storage stack. This is how to read iowait and PSI correctly, then work through the five failure families that actually produce storage stalls: device saturation, memory pressure, synchronous writes, throttling, and latency that lives above the block layer entirely.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/troubleshooting-high-io-wait-linux/">Troubleshooting High I/O Wait Without Blaming the Disk</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The ticket says the app is slow. You SSH in, run <code>top</code>, and there it is: <code>wa</code> sitting at forty percent while user CPU barely moves. Obvious conclusion, the disk is dying. Somebody opens a ticket with the host, everyone waits, and the host replies that the volume looks healthy on their side.</p>



<p class="wp-block-paragraph">Sometimes the disk really is dying. More often it isn&#8217;t, and the number you&#8217;re staring at is not measuring what you think it&#8217;s measuring.</p>



<p class="wp-block-paragraph">This post covers troubleshooting high I/O wait the way I&#8217;d actually do it on a production box: what the metric means, why it lies in both directions, and how to work through the five failure families that produce it.</p>



<h2 class="wp-block-heading">What high I/O wait actually measures</h2>



<p class="wp-block-paragraph">Here&#8217;s the definition that matters: iowait is a <em>subcategory of idle time</em>. For a given CPU, it&#8217;s the time that CPU spent doing nothing while at least one task scheduled on it had an outstanding block I/O request.</p>



<p class="wp-block-paragraph">Two consequences fall out of that, and both will burn you. <strong>High iowait can mean nothing is wrong.</strong> If the box has spare CPU and one process is pulling a large file off disk, the CPU genuinely has nothing else to do. Copying a backup will push iowait toward 100 percent on an idle host, and that&#8217;s a working system, not a fault.</p>



<p class="wp-block-paragraph"><strong>Low iowait can hide a severe problem.</strong> This is the one that costs people days. If the CPU has other runnable work, the kernel context switches to it and that time gets counted as user or system, not iowait. A busy application server can be stalling badly on storage while <code>wa</code> reads two percent, because the CPU is never actually idle.</p>



<p class="wp-block-paragraph">Add the averaging problem on top. The <code>wa</code> figure in <code>top</code> is averaged across every core. One thread pinned to one core and blocked hard on disk shows up as roughly three percent on a 32-core machine. Press <code>1</code> in <code>top</code> to break out per-CPU lines before you conclude anything from the aggregate.</p>



<p class="wp-block-paragraph">So iowait is a hint, not a verdict. Treat it as the thing that starts the investigation, never the thing that ends it.</p>



<h2 class="wp-block-heading">Start with pressure, not percentages</h2>



<p class="wp-block-paragraph">Pressure Stall Information is the metric I reach for first, and it exists precisely because iowait is a poor proxy for pain. It answers the question you actually care about: how much time did tasks lose because they couldn&#8217;t get I/O?</p>



<pre class="wp-block-code"><code>cat /proc/pressure/io</code></pre>



<p class="wp-block-paragraph">You get two lines. <code>some</code> is the share of time at least one task was stalled waiting on I/O. <code>full</code> is the share of time <em>every</em> non-idle task was stalled at once, which is CPU capacity being burned for nothing. The <code>avg10</code>, <code>avg60</code> and <code>avg300</code> fields are percentages over the last 10, 60 and 300 seconds. <code>total</code> is a cumulative microsecond counter since boot, which is what you want for graphing because averages smooth away short spikes.</p>



<p class="wp-block-paragraph">The practical rule I use: a rising <code>full</code> line on <code>io</code> is a real user-visible problem almost every time. A high <code>some</code> line with a flat <code>full</code> line usually means one noisy job is waiting while everything else gets on with its life.</p>



<p class="wp-block-paragraph">If <code>/proc/pressure</code> doesn&#8217;t exist, the kernel was built without <code>CONFIG_PSI</code>, or it was built with PSI compiled in but disabled by default, in which case you need <code>psi=1</code> on the kernel command line. On cgroup v2 systems you also get per-cgroup pressure, which is how you narrow from &#8220;the host is stalling&#8221; to &#8220;this container is stalling&#8221; in one command:</p>



<pre class="wp-block-code"><code># Pressure for one systemd slice, useful on any modern distro
cat /sys/fs/cgroup/system.slice/io.pressure

# Pressure for every leaf cgroup that has it, sorted by nothing,
# just eyeball the full lines
find /sys/fs/cgroup -name io.pressure -exec sh -c 
  'printf "%s: " "$1"; sed -n 2p "$1"' _ {} ;</code></pre>



<p class="wp-block-paragraph">If you run Prometheus, node_exporter has exposed these since it gained a pressure collector. The counters are <code>node_pressure_io_waiting_seconds_total</code> for <code>some</code> and <code>node_pressure_io_stalled_seconds_total</code> for <code>full</code>. Rate those and you have a far better storage alert than any threshold on <code>%iowait</code> will ever be. Grafana Cloud and Netdata both chart PSI out of the box if you&#8217;d rather not build the panels yourself.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Failure family one: the device really is saturated</h2>



<p class="wp-block-paragraph">This is the case everyone assumes, so get it confirmed or eliminated quickly.</p>



<pre class="wp-block-code"><code>iostat -xy 1</code></pre>



<p class="wp-block-paragraph">The <code>-x</code> gives extended per-device stats. The <code>-y</code> matters more than people realise: without it, the first report is a since-boot average, and reading that first block is the single most common iostat mistake there is. It will tell you the disk has been fine all year, which is true and useless.</p>



<p class="wp-block-paragraph">Now the columns worth your attention:</p>



<ul class="wp-block-list">
<li><strong>r_await and w_await</strong>: average milliseconds a read or write took, queue time included. This is the closest thing to &#8220;how slow does storage feel&#8221;. Split reads from writes, because they fail very differently.</li>
<li><strong>aqu-sz</strong>: average queue depth. Requests either queued or in service. A depth that climbs while throughput stays flat is the signature of a genuinely saturated device.</li>
<li><strong>r/s, w/s, rkB/s, wkB/s</strong>: the actual workload. Compare against what the device or volume class is rated for.</li>
<li><strong>%util</strong>: treat with suspicion, see below.</li>
</ul>



<p class="wp-block-paragraph"><strong>The %util trap.</strong> That column counts the fraction of time at least one request was in flight. On a spinning disk that serviced one request at a time, it was a fair saturation signal. On NVMe and on cloud volumes that handle many requests in parallel, a device can sit at 100 percent while running at a fraction of its real capacity. Provisioning a bigger volume off the back of a 99 percent <code>%util</code> reading is a common and expensive way to change nothing. Use <code>aqu-sz</code> and the await columns instead. Similarly, <code>svctm</code> was deprecated and dropped from newer sysstat output because it was unreliable on parallel devices; ignore it if an old build still prints it.</p>



<p class="wp-block-paragraph">Once you know the device is busy, find the culprit. <code>pidstat -d 1</code> gives per-process read and write rates from the same sysstat package and needs nothing special enabled. <code>iotop -o</code> is friendlier, but on kernels from 5.14 onward per-task delay accounting is off by default, so the <code>SWAPIN</code> and <code>IO&gt;</code> columns come back empty until you turn it on:</p>



<pre class="wp-block-code"><code>sudo sysctl kernel.task_delayacct=1
sudo iotop -oPa
# turn it back off when you're done, it costs a little scheduler overhead
sudo sysctl kernel.task_delayacct=0</code></pre>



<p class="wp-block-paragraph">And check the boring things before the clever ones. <code>dmesg -T | grep -iE 'i/o error|reset|medium'</code> catches a failing drive or a flapping controller. <code>smartctl -a /dev/sda</code> catches reallocated sectors on hardware you own. If either of those turns something up, stop reading and replace the disk.</p>



<h2 class="wp-block-heading">Failure family two: memory pressure in an I/O costume</h2>



<p class="wp-block-paragraph">This is the failure family I&#8217;d check second, and it&#8217;s invisible if you only look at storage.</p>



<p class="wp-block-paragraph">When RAM gets tight, the kernel starts evicting page cache and swapping. Both generate disk traffic. The disk gets busy, iowait climbs, and every graph points at storage while the actual constraint is memory. Give the box more RAM and the &#8220;disk problem&#8221; disappears.</p>



<pre class="wp-block-code"><code># si and so are swap-in and swap-out in KB/s.
# Sustained non-zero values are your answer.
vmstat 1

# Are we thrashing writeback? Dirty pages waiting to be written.
grep -E 'Dirty|Writeback' /proc/meminfo

# The direct question
cat /proc/pressure/memory</code></pre>



<p class="wp-block-paragraph">If memory pressure and I/O pressure rise together, fix memory first. Everything else is downstream.</p>



<p class="wp-block-paragraph">There&#8217;s a related trap on the write side. The kernel buffers writes in page cache and flushes them in the background, governed by <code>vm.dirty_background_ratio</code> and <code>vm.dirty_ratio</code>. Cross the background threshold and flusher threads start writing. Cross the hard <code>vm.dirty_ratio</code> threshold and writing processes are forced to do writeback themselves, synchronously. That transition is brutal, and on a box with plenty of RAM and a modest disk the defaults let you accumulate gigabytes of dirty pages and then pay for all of them at once. Lowering the ratios makes writeback more frequent and less spiky, trading away a little write batching. Measure before you commit to it.</p>



<h2 class="wp-block-heading">Failure family three: small synchronous writes</h2>



<p class="wp-block-paragraph">Throughput can be trivial and the system still crawls. A workload doing thousands of tiny <code>fsync</code> calls a second is bound by round-trip latency, not bandwidth, and no amount of extra megabytes per second will help.</p>



<p class="wp-block-paragraph">Databases are the usual source, because durability has a cost and that cost is a flush. MySQL and MariaDB expose it through <code>innodb_flush_log_at_trx_commit</code>; PostgreSQL through <code>synchronous_commit</code>. Loosening either buys real throughput and trades away durability on power loss or crash. That can be a perfectly reasonable trade for a cache or an analytics replica, and a terrible one for anything holding orders or payments. Decide it explicitly, write down what you decided, and don&#8217;t let it be a thing somebody changed once at 2am.</p>



<p class="wp-block-paragraph">The other common source is logging. Application logs, access logs, container JSON logs and a database sharing one volume will starve each other. Splitting hot write paths onto separate devices is unglamorous and works.</p>



<h2 class="wp-block-heading">Failure family four: throttled, not saturated</h2>



<p class="wp-block-paragraph">A throttled device and a saturated device look almost identical from inside the guest: high await, growing queue, miserable latency. The difference is that no amount of tuning inside the box will fix a throttle. Three flavours are worth knowing.</p>



<ul class="wp-block-list">
<li><strong>Cloud volume limits.</strong> Managed block storage gives you a baseline of IOPS and throughput, sometimes with a burst allowance on top. Burn the burst and you drop to baseline, and everything that was fine in testing falls over in production. The tell is a latency cliff that arrives at a consistent point into a workload rather than tracking load smoothly. On AWS, watch the volume-level CloudWatch metrics such as <code>BurstBalance</code> on burstable volume types and <code>VolumeQueueLength</code>; other providers publish equivalents. The fix is a volume class change, not a sysctl.</li>
<li><strong>cgroup limits you set yourself.</strong> On cgroup v2, <code>io.max</code> caps a cgroup at a given bytes-per-second or IOPS figure, and <code>io.weight</code> shares bandwidth proportionally under contention. A limit that made sense when it was written can become the bottleneck a year later, and nothing in the container&#8217;s own metrics announces it.</li>
<li><strong>Neighbours on shared hardware.</strong> On oversubscribed VPS plans your storage is shared, and someone else&#8217;s backup job is your latency spike. This is where cheap and expensive hosting genuinely differ. Budget providers like Contabo and InterServer are fine value for a lot of workloads, but if storage latency is load-bearing for you, look for plans with dedicated NVMe rather than shared pools, and test before you migrate anything that matters.</li>
</ul>



<pre class="wp-block-code"><code># Per-device I/O accounting for a cgroup, including any throttling
cat /sys/fs/cgroup/system.slice/docker-&lt;id&gt;.scope/io.stat

# Is a cap set? An empty file means no limit on that cgroup.
cat /sys/fs/cgroup/system.slice/docker-&lt;id&gt;.scope/io.max</code></pre>



<p class="wp-block-paragraph">One more signal while you&#8217;re here. In <code>top</code>, the <code>st</code> column is steal time, meaning the hypervisor gave your vCPU&#8217;s slice to someone else. Steal and iowait rising together on a shared host is a strong hint that the problem is the neighbourhood, not your configuration.</p>



<h2 class="wp-block-heading">Failure family five: the latency isn&#8217;t in the block layer</h2>



<p class="wp-block-paragraph">Here&#8217;s the failure mode that wastes the most time, because every storage metric you check will look healthy.</p>



<p class="wp-block-paragraph"><code>iostat</code> measures the block layer. It cannot see anything above it. So if your latency is in NFS, in a network filesystem, in an overlay or FUSE mount, in filesystem lock contention, or in a journal that&#8217;s serialising writes, the device will report sub-millisecond service times while your application waits tens of milliseconds per operation.</p>



<p class="wp-block-paragraph">The diagnosis is the gap. Measure latency at the application&#8217;s layer and at the block layer, and if the two numbers disagree wildly, the problem lives in between.</p>



<pre class="wp-block-code"><code># Block layer latency as a histogram. Look for a bimodal shape
# or a long tail rather than the average.
sudo biolatency-bpfcc -m 10 1      # Debian/Ubuntu naming
sudo /usr/share/bcc/tools/biolatency -m 10 1   # RHEL family

# Filesystem operations slower than 10ms, with the process and file
sudo ext4slower-bpfcc 10
sudo xfsslower-bpfcc 10

# Everything at the VFS layer, higher overhead, use briefly
sudo fileslower-bpfcc 10</code></pre>



<p class="wp-block-paragraph">If <code>ext4slower</code> is full of hundred-millisecond operations and <code>biolatency</code> shows everything completing in microseconds, you have your answer and it is not the disk. For NFS specifically, <code>nfsiostat</code> splits out RTT from execution time, which separates network cost from server cost. Install the tooling with <code>bpfcc-tools</code> on Debian and Ubuntu, or <code>bcc-tools</code> on RHEL, AlmaLinux and Rocky.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">A working order for troubleshooting high I/O wait</h2>



<p class="wp-block-paragraph">On an unfamiliar box, this is the sequence I work through. Each step either finds the problem or eliminates a family.</p>



<ol class="wp-block-list">
<li>Read <code>/proc/pressure/io</code>. If <code>full</code> is near zero, the stall is not hurting the whole system and you should widen the search before you tune storage.</li>
<li>Check <code>/proc/pressure/memory</code> at the same time. If it&#8217;s rising too, fix memory first.</li>
<li>Run <code>iostat -xy 1</code> for thirty seconds. Note <code>r_await</code>, <code>w_await</code> and <code>aqu-sz</code> per device. Ignore <code>%util</code>.</li>
<li>List processes stuck in uninterruptible sleep: <code>ps -eo state,pid,comm | awk '$1 ~ /^D/'</code>. That&#8217;s your blocked set, and it names the workload.</li>
<li>Attribute the traffic with <code>pidstat -d 1</code>. Confirm the process you suspect is the process actually doing the I/O.</li>
<li>If await is high, decide saturated versus throttled. Check cgroup <code>io.max</code>, check provider volume metrics, check steal time.</li>
<li>If await is low but the application is slow, jump to the layer gap. Run <code>biolatency</code> and <code>ext4slower</code> together and compare.</li>
<li>Only now consider tuning: dirty ratios, I/O scheduler, flush settings, splitting volumes.</li>
</ol>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li><strong>Reading iostat&#8217;s first report.</strong> It&#8217;s a since-boot average. Use <code>-y</code>, or discard the first block.</li>
<li><strong>Trusting %util on NVMe or cloud volumes.</strong> Parallel devices break the assumption it was built on.</li>
<li><strong>Alerting on a fixed %iowait threshold.</strong> It fires on healthy backups and stays silent during real stalls on busy hosts.</li>
<li><strong>Changing the I/O scheduler first.</strong> Going from <code>mq-deadline</code> to <code>none</code> on NVMe is sometimes right, but it&#8217;s a tuning step, not a diagnosis, and it fixes nothing if you&#8217;re throttled.</li>
<li><strong>Adding swap to fix swapping.</strong> More swap on a memory-starved box means more disk traffic, not less.</li>
<li><strong>Stopping at the block layer.</strong> If every storage metric looks clean and the app is still slow, you&#8217;re measuring at the wrong layer, not measuring a healthy system.</li>
<li><strong>Testing with the wrong benchmark.</strong> A sequential <code>dd</code> tells you nothing about a workload doing random 4K synchronous writes. Use <code>fio</code> and match the real access pattern.</li>
</ul>



<h2 class="wp-block-heading">Best practices worth the effort</h2>



<ul class="wp-block-list">
<li><strong>Record PSI continuously.</strong> Storage incidents are short and bursty. Without history you&#8217;re guessing about something that already ended.</li>
<li><strong>Capture a baseline while things are healthy.</strong> Save an <code>iostat -xy</code> sample and a PSI reading from a normal day. &#8220;Is 8ms bad?&#8221; is unanswerable without one.</li>
<li><strong>Keep sar running.</strong> The sysstat package can archive metrics on a schedule, which means <code>sar -d</code> can show you what happened at 3am. Enable it before you need it.</li>
<li><strong>Separate hot write paths.</strong> Database data, database logs and application logs on separate volumes stops one from starving the others.</li>
<li><strong>Cap container logs.</strong> An uncapped JSON log driver quietly filling the root volume is a recurring cause of host-wide stalls.</li>
<li><strong>Alert on latency and pressure, not utilisation.</strong> Users feel <code>w_await</code> and PSI <code>full</code>. Nobody has ever felt a percentage of device busy time.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">What is a normal or acceptable I/O wait percentage?</h3>



<p class="wp-block-paragraph">There isn&#8217;t one, and any article that gives you a single number is guessing. Iowait scales with how idle your CPUs are, so the same workload produces very different figures on a busy server and a quiet one. Judge against your own baseline, and prefer PSI, which measures lost time directly rather than as a share of idle.</p>



<h3 class="wp-block-heading">Can high I/O wait be caused by something other than the disk?</h3>



<p class="wp-block-paragraph">Regularly. Memory pressure driving swap and page cache eviction is the most common non-disk cause. Network filesystems, hypervisor contention on shared hosts, and cgroup or cloud volume throttling all produce the same symptom. The block device being genuinely slow is one possibility among several.</p>



<h3 class="wp-block-heading">Why does iostat show 100% utilisation when the disk isn&#8217;t busy?</h3>



<p class="wp-block-paragraph">Because <code>%util</code> measures the fraction of time at least one request was in flight, which made sense for devices that handled one request at a time. NVMe drives and cloud volumes process many requests in parallel, so they can be continuously occupied while nowhere near capacity. Read <code>aqu-sz</code> and the await columns instead.</p>



<h3 class="wp-block-heading">How do I find which process is causing I/O wait?</h3>



<p class="wp-block-paragraph">Start with <code>pidstat -d 1</code>, which needs no special kernel settings. List processes in uninterruptible sleep with <code>ps -eo state,pid,comm</code> and filter for state D to see who is actually blocked. <code>iotop -o</code> gives a live view but needs <code>kernel.task_delayacct=1</code> on recent kernels for its percentage columns to populate.</p>



<h3 class="wp-block-heading">Does adding more RAM reduce I/O wait?</h3>



<p class="wp-block-paragraph">Often, yes, and it&#8217;s underrated. More RAM means a larger page cache, fewer reads reaching the device, and less swapping. If <code>vmstat</code> shows sustained swap activity or memory pressure tracks I/O pressure, RAM is likely the cheapest fix available.</p>



<h3 class="wp-block-heading">Should I change the I/O scheduler to fix high I/O wait?</h3>



<p class="wp-block-paragraph">Only after you know the device is the bottleneck. Check the current setting with <code>cat /sys/block/sda/queue/scheduler</code>. On NVMe, <code>none</code> is a common and sensible default because the hardware already does the queuing. On spinning disks, <code>mq-deadline</code> or <code>bfq</code> give better latency behaviour. It&#8217;s a tuning knob, not a diagnostic, and it will not help if you&#8217;re being throttled.</p>



<h3 class="wp-block-heading">Why is my application slow when iostat says storage is fine?</h3>



<p class="wp-block-paragraph">Because <code>iostat</code> only sees the block layer. Latency in NFS, FUSE, overlay filesystems, journal serialisation or filesystem locking is entirely invisible to it. Run <code>ext4slower</code> or <code>xfsslower</code> alongside <code>biolatency</code>: if the filesystem is slow and the device is fast, the gap between them is your problem.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">High I/O wait is a symptom that points in a general direction, not a diagnosis. It&#8217;s a slice of idle time, averaged across cores, measured at one specific layer of the stack. It goes up when nothing is wrong and stays flat when something is very wrong.</p>



<p class="wp-block-paragraph">Read PSI to find out whether anyone is actually suffering. Read <code>r_await</code>, <code>w_await</code> and <code>aqu-sz</code> to find out whether the device is the constraint. Compare filesystem latency against block latency to find out whether you&#8217;re even looking at the right layer. Do those three things and the answer usually falls out in a few minutes, instead of after a week of arguing with your hosting provider about a volume that was fine all along.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need someone to look at your storage stalls?</h2>



<p class="wp-block-paragraph">I work with teams on exactly this kind of problem, usually the ones that have already survived a round of &#8220;the host says the disk is fine&#8221;. Things I can help with:</p>



<ul class="wp-block-list">
<li>Diagnosing a live or recurring I/O stall and identifying which failure family it belongs to</li>
<li>Setting up PSI, sar and per-cgroup I/O metrics in Prometheus and Grafana, with alerts that fire on latency rather than utilisation</li>
<li>Separating cloud volume throttling from genuine device saturation, and sizing storage correctly instead of guessing upward</li>
<li>Tuning writeback, dirty ratios and I/O schedulers for a specific workload, with before-and-after measurements</li>
<li>Tracing container and Kubernetes I/O contention through cgroup v2 limits, overlay filesystems and log drivers</li>
<li>Benchmarking with <code>fio</code> against your real access pattern so capacity decisions rest on something other than a <code>dd</code> run</li>
</ul>



<p class="wp-block-paragraph">If you&#8217;ve got an <code>iostat</code> capture, a PSI reading or just a graph that doesn&#8217;t make sense, send it over and I&#8217;ll tell you what I see in it.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/troubleshooting-high-io-wait-linux/">Troubleshooting High I/O Wait Without Blaming the Disk</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Synthetic vs Real User Monitoring: What Your Green Dashboard Isn&#8217;t Telling You</title>
		<link>https://john-nessime.com/blog/devops/synthetic-vs-real-user-monitoring/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Wed, 19 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[Blackbox Exporter]]></category>
		<category><![CDATA[Core Web Vitals]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Grafana Faro]]></category>
		<category><![CDATA[Health Checks]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[Real User Monitoring]]></category>
		<category><![CDATA[Reliability Engineering]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Synthetic Monitoring]]></category>
		<category><![CDATA[Uptime Monitoring]]></category>
		<category><![CDATA[Website Downtime]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=207</guid>

					<description><![CDATA[<p>Uptime checks and RUM measure different things, and each one is structurally blind to failures the other catches. A practical breakdown of what synthetic probes miss, why RUM goes quiet during a real outage, and how to combine them without paying for two overlapping platforms.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/synthetic-vs-real-user-monitoring/">Synthetic vs Real User Monitoring: What Your Green Dashboard Isn&#8217;t Telling You</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The uptime dashboard is green for the whole month. Support has nine tickets from the same week saying the checkout button does nothing.</p>



<p class="wp-block-paragraph">Both are accurate. The probe hits the homepage every minute from a datacenter, gets a 200 back, and moves on. It never logs in, never adds anything to a cart, and never runs the third-party payment script that is throwing a JavaScript error on one browser version. The check was answering a question nobody was asking.</p>



<p class="wp-block-paragraph">That gap is what the synthetic vs real user monitoring argument is actually about. It isn&#8217;t a tooling preference or a budget line. It&#8217;s about which failures each approach is structurally incapable of seeing, and what happens when you only run one of them.</p>



<p class="wp-block-paragraph">This post covers what each method really measures, the blind spot in each that bites hardest, how to choose when you can only afford one, and how to wire both together so they explain each other instead of arguing.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The blind spot that bites hardest: silence is not health</h2>



<p class="wp-block-paragraph">Start with the one that catches people out, because it is invisible until the worst possible moment.</p>



<p class="wp-block-paragraph">Real user monitoring is passive. A script in the page collects timings and errors from actual visitors and beacons them back. No visitors, no data. So when your site is genuinely, completely down, the browser never loads, the RUM script never executes, and your error rate goes to zero.</p>



<p class="wp-block-paragraph">Zero errors. A flat, quiet, beautiful dashboard. During an outage.</p>



<p class="wp-block-paragraph">I&#8217;ve seen teams build alert rules on RUM error counts and then wonder why nothing fired. A threshold alert on &#8220;errors above X&#8221; cannot fire on a metric that has stopped reporting. If you alert on RUM at all, you need a companion rule on the absence of data, and that rule needs a sensible window, because low-traffic sites go quiet at 3am for entirely normal reasons.</p>



<p class="wp-block-paragraph">The mirror-image problem on the synthetic side is quieter but just as expensive. A scheduled probe only ever tests what you told it to test. Every path you didn&#8217;t script is unmonitored, and it looks exactly the same on the dashboard as a path you did script and that&#8217;s working. Coverage gaps and healthy systems are visually identical.</p>



<p class="wp-block-paragraph">So: synthetic monitoring is blind to what you forgot to check. RUM is blind when nobody is looking. Neither of those is a small caveat.</p>



<h2 class="wp-block-heading">Synthetic monitoring: what a probe actually measures</h2>



<p class="wp-block-paragraph">A synthetic check is an active request on a schedule, from infrastructure you don&#8217;t own, against an endpoint you do. It ranges from a plain HTTP GET up to a full scripted browser session that logs in and walks a purchase flow.</p>



<p class="wp-block-paragraph">The simplest useful version is something you can run by hand right now. This asks curl to throw away the body and print only the timing breakdown, which tells you <em>where</em> the time went rather than just how much of it there was:</p>



<pre class="wp-block-code"><code>curl -o /dev/null -s -w 
  'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} code=%{http_code}n' 
  https://example.com/</code></pre>



<p class="wp-block-paragraph">Each value is cumulative from the start of the request, so you subtract to get the phase. A slow <code>time_namelookup</code> is DNS. A gap between <code>time_connect</code> and <code>time_appconnect</code> is the TLS handshake. A gap between <code>time_appconnect</code> and <code>time_starttransfer</code> is your application thinking. That last one is the number that actually correlates with user-visible slowness on a server-rendered page.</p>



<p class="wp-block-paragraph">If you&#8217;re already running Prometheus, the self-hosted version of this is the blackbox exporter. It probes endpoints from outside and exposes the result as metrics. The config below is the whole trick: Prometheus doesn&#8217;t scrape the target, it scrapes the exporter and passes the target as a URL parameter.</p>



<pre class="wp-block-code"><code>scrape_configs:
  - job_name: blackbox-http
    metrics_path: /probe
    params:
      module: [http_2xx]
    static_configs:
      - targets:
          - https://example.com/
          - https://example.com/checkout
    relabel_configs:
      - source_labels: [__address__]
        target_label: __param_target
      - source_labels: [__param_target]
        target_label: instance
      - target_label: __address__
        replacement: 127.0.0.1:9115</code></pre>



<p class="wp-block-paragraph">Those three relabel rules have to run in that order: copy the target into the query parameter, keep the original URL as the <code>instance</code> label so your graphs are readable, then overwrite the scrape address with the exporter. Get the order wrong and Prometheus scrapes the exporter&#8217;s own metrics endpoint, you get no <code>probe_*</code> series at all, and everything looks fine because there&#8217;s nothing red to see.</p>



<p class="wp-block-paragraph">The metrics you&#8217;ll actually alert on:</p>



<pre class="wp-block-code"><code>groups:
  - name: blackbox
    rules:
      - alert: EndpointDown
        expr: probe_success == 0
        for: 3m
        labels:
          severity: critical
      - alert: CertExpiringSoon
        expr: probe_ssl_earliest_cert_expiry - time() &lt; 86400 * 21
        for: 1h
        labels:
          severity: warning</code></pre>



<p class="wp-block-paragraph">The certificate one is worth setting up even if you do nothing else. It&#8217;s the single highest-value synthetic check most small teams are missing, and it catches a failure mode that takes an entire site offline with no warning and no gradual degradation to notice first.</p>



<h3 class="wp-block-heading">Where synthetic monitoring wins</h3>



<ul class="wp-block-list">
<li><strong>It works with zero traffic.</strong> A B2B app with forty users, a staging environment, a newly launched site: synthetic is the only thing that will tell you anything at all.</li>

<li><strong>It&#8217;s a controlled baseline.</strong> Same location, same schedule, same request. When the number moves, something on your side moved. RUM numbers shift because a marketing campaign brought in traffic on older phones.</li>

<li><strong>It tests paths users haven&#8217;t taken yet.</strong> A new region, a failover route, a payment provider you just switched to. RUM only ever sees sessions that already happened.</li>

<li><strong>It runs before deploy.</strong> The same scripted journey can gate a release in CI and then run on a schedule against production.</li>

<li><strong>It&#8217;s what an SLA is measured against.</strong> Availability commitments need a defined, repeatable observer. Real users are not that.</li>
</ul>



<h3 class="wp-block-heading">Where synthetic monitoring falls down</h3>



<ul class="wp-block-list">
<li><strong>The probe is on a better connection than your users.</strong> Datacenter fibre, a modern browser engine, no browser extensions, no battery throttling. It is a best case, permanently.</li>

<li><strong>Coverage equals imagination.</strong> Whatever you didn&#8217;t think to script is a silent gap, and it will be the thing that breaks.</li>

<li><strong>Scripted journeys rot.</strong> Every selector change, every A/B test, every consent banner redesign can break the check. Flaky synthetics that cry wolf get muted, and muted checks are worse than no checks.</li>

<li><strong>Cost scales multiplicatively.</strong> Frequency times locations times journeys. A one-minute browser check from eight regions is a very different bill from one HTTP check every five minutes.</li>
</ul>



<h2 class="wp-block-heading">Real user monitoring: what the browser actually reports</h2>



<p class="wp-block-paragraph">RUM is a small JavaScript agent that reads the browser&#8217;s own performance APIs and ships the results somewhere. Google&#8217;s <code>web-vitals</code> library is the reference implementation for the metrics that matter, and it&#8217;s deliberately tiny:</p>



<pre class="wp-block-code"><code>import { onCLS, onINP, onLCP } from 'web-vitals';

function send(metric) {
  navigator.sendBeacon('/rum', JSON.stringify({
    name:   metric.name,
    value:  metric.value,
    rating: metric.rating,
    id:     metric.id,
    path:   location.pathname
  }));
}

onCLS(send);
onINP(send);
onLCP(send);</code></pre>



<p class="wp-block-paragraph">Three things are worth understanding about that snippet. The callbacks fire when the metric is <em>final</em>, not on a timer, which is why Interaction to Next Paint can only be reported near the end of a session. <code>sendBeacon</code> is used instead of fetch because it survives the page being closed. And <code>metric.rating</code> gives you the good / needs-improvement / poor bucket without you hardcoding thresholds that Google may revise.</p>



<p class="wp-block-paragraph">If you&#8217;d rather not build the collection side, the managed options split roughly into full-stack platforms (Datadog RUM, New Relic, Dynatrace), error-tracking tools that grew into RUM (Sentry), and open-source agents you can point at your own backend. Grafana Faro is the notable one in that last group: a browser SDK that ships web vitals, errors and traces to a collector you run yourself or to Grafana Cloud, which means your frontend signals land next to your Prometheus metrics and Loki logs instead of in a separate tab.</p>



<h3 class="wp-block-heading">Where RUM wins</h3>



<ul class="wp-block-list">
<li><strong>It&#8217;s the only source of truth for Core Web Vitals.</strong> Google assesses LCP, INP and CLS from field data at the 75th percentile, via the Chrome User Experience Report. A perfect Lighthouse score is a lab result and carries no weight in that assessment.</li>

<li><strong>It surfaces the long tail.</strong> Mid-range Android on a congested mobile network is a cohort no synthetic probe represents, and it&#8217;s frequently the cohort that fails.</li>

<li><strong>It segments the way the business thinks.</strong> By country, by ISP, by device class, by page template, by traffic source. That&#8217;s how you find out the paid social landing page is the slow one.</li>

<li><strong>It catches client-side-only failures.</strong> A third-party tag that starts blocking render, a JS exception on one browser version, a consent script that stalls. The HTML came back 200. The page is still broken.</li>
</ul>



<h3 class="wp-block-heading">Where RUM falls down</h3>



<ul class="wp-block-list">
<li><strong>No traffic, no signal</strong> — the silence problem above, plus useless percentiles on any low-volume page.</li>

<li><strong>It&#8217;s reactive by construction.</strong> Every data point is a user who already had the bad experience. RUM cannot warn you about anything.</li>

<li><strong>You are sampled, ad-blocked and consent-gated.</strong> A meaningful slice of visitors never report, and they&#8217;re not a random slice. Privacy-conscious, extension-heavy users skew toward desktop and toward certain regions.</li>

<li><strong>The agent costs what it measures.</strong> You&#8217;re adding JavaScript to a page in order to find out how much JavaScript is slowing it down. Keep the agent small and load it late.</li>

<li><strong>Field data lags.</strong> CrUX aggregates over a trailing window, so a fix you shipped this week won&#8217;t show up in the assessment for weeks. Ship it, then wait, and resist re-litigating.</li>
</ul>



<h2 class="wp-block-heading">Synthetic vs real user monitoring: the honest split</h2>



<p class="wp-block-paragraph">Strip away the vendor framing and the division is clean. Synthetic answers <em>&#8220;is the thing I defined still working?&#8221;</em> RUM answers <em>&#8220;what are people actually experiencing right now?&#8221;</em></p>



<p class="wp-block-paragraph">Which means they belong to different jobs. Synthetic owns availability, contractual uptime, certificate and DNS health, and regression gates in CI. RUM owns experience, Core Web Vitals, conversion-adjacent performance work, and prioritisation. If you find yourself arguing about which is &#8220;better&#8221;, you&#8217;re comparing a smoke detector to a customer satisfaction survey.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">Synthetic tells you something broke. RUM tells you whether it mattered, and to whom.</p>
</blockquote>



<h2 class="wp-block-heading">How I&#8217;d decide what to run first</h2>



<p class="wp-block-paragraph">Most teams can&#8217;t stand up both properly in the same quarter. Here&#8217;s the order I&#8217;d work in.</p>



<ol class="wp-block-list">
<li><strong>Get an external uptime check up today.</strong> Any of them. UptimeRobot, Better Stack, Pingdom, or blackbox exporter if you already run Prometheus. It must live outside your own infrastructure, or it dies with the thing it&#8217;s watching. Do check the current terms on free tiers before you build a client&#8217;s alerting on one, since several now restrict commercial use.</li>

<li><strong>Add TLS expiry and DNS resolution checks.</strong> Cheap, and they catch total outages that have no warning phase.</li>

<li><strong>Decide whether you have traffic.</strong> If a typical page gets meaningful daily sessions, RUM will produce usable percentiles and should come next. If it doesn&#8217;t, skip RUM entirely for now and spend the effort on scripted journeys instead.</li>

<li><strong>Script your two or three money paths.</strong> Login, search, checkout. Not twelve. Each one is code you now have to maintain. Checkly is built around exactly this if you want the checks living in your repo as Playwright specs; Grafana Cloud Synthetic Monitoring covers the same ground with k6 scripts if your dashboards already live in Grafana.</li>

<li><strong>Add probe locations only where you have users.</strong> Frequency times locations is your bill. Two well-chosen regions beat eight decorative ones.</li>

<li><strong>Layer RUM last, and start read-only.</strong> Look at it for a month before you attach a single alert to it. You need to know what normal looks like per page template.</li>
</ol>



<p class="wp-block-paragraph">One thing worth checking before you buy anything: what your hosting already gives you. Managed WordPress plans and VPS providers like InterServer often include basic availability alerting and a status page, and Cloudflare exposes edge-side request and error data for anything behind it. That may be enough of the availability layer that you can spend the budget on the experience layer instead.</p>



<h2 class="wp-block-heading">Mistakes that look like monitoring</h2>



<ul class="wp-block-list">
<li><strong>Running the check from inside the network it monitors.</strong> If your probe and your app share a VPC, a datacenter, or a DNS resolver, a shared failure takes out both and you learn nothing.</li>

<li><strong>Probing a health endpoint that always returns 200.</strong> A route that returns a hardcoded <code>OK</code> without touching the database is testing your web server&#8217;s ability to serve a string. Make the check exercise a real dependency, or add a content assertion on a page that only renders correctly when the database answers.</li>

<li><strong>Alerting on averages.</strong> A mean page load time hides every user who had a terrible one. Use p75 for experience work, because that&#8217;s what Google assesses, and p95 or p99 when you&#8217;re hunting outliers.</li>

<li><strong>Alerting on a single failed probe.</strong> One-off network blips are constant. Require consecutive failures, or failures from more than one location, before you wake anyone.</li>

<li><strong>Treating a Lighthouse score as a monitoring signal.</strong> It&#8217;s a diagnostic on a simulated device. Useful for finding the cause, worthless as an assessment of what users get.</li>

<li><strong>Letting synthetic traffic pollute analytics.</strong> Scripted browser checks look like sessions. Filter them out by user agent or a header, or your conversion rate quietly develops a dent.</li>

<li><strong>No alert on missing data.</strong> Applies to both sides. A probe that stopped running and a RUM stream that stopped reporting both look like calm.</li>
</ul>



<h2 class="wp-block-heading">Making the two views agree</h2>



<p class="wp-block-paragraph">The payoff for running both isn&#8217;t two dashboards. It&#8217;s that each one narrows the search when the other fires.</p>



<ul class="wp-block-list">
<li><strong>Use consistent labels across both.</strong> Same page-template names, same region names, same environment tags. Otherwise correlating them is manual archaeology every time.</li>

<li><strong>Define availability from synthetic, experience from RUM,</strong> and write it down. <code>avg_over_time(probe_success[30d])</code> is your uptime figure. p75 INP is your experience figure. Don&#8217;t let the two SLOs drift into using each other&#8217;s data.</li>

<li><strong>Let RUM tell you what to script next.</strong> When RUM shows a page template failing for one device class, that&#8217;s a synthetic check waiting to be written, throttled to match.</li>

<li><strong>Let synthetic explain RUM regressions.</strong> RUM says LCP got worse on Tuesday. Synthetic&#8217;s stored waterfall from Tuesday tells you which resource started arriving late.</li>

<li><strong>Alert on synthetic, investigate with RUM.</strong> Pages should come from the deterministic signal. The noisy, high-cardinality one is where you go to figure out the blast radius.</li>

<li><strong>Keep the scripted journeys in version control</strong> alongside the app they test, so a selector change and its check update land in the same pull request.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Is uptime monitoring the same thing as synthetic monitoring?</h3>



<p class="wp-block-paragraph">Uptime monitoring is the simplest form of synthetic monitoring: a scheduled request checking for a successful response. Full synthetic monitoring extends that to multi-step browser journeys, API sequences, DNS, TCP and traceroute checks. Same category, different depth.</p>



<h3 class="wp-block-heading">Can real user monitoring replace uptime checks?</h3>



<p class="wp-block-paragraph">No, and this is the most expensive misunderstanding in the whole synthetic vs real user monitoring discussion. RUM depends on pages loading. When the site is down, the RUM stream goes quiet rather than red, so an outage looks identical to a slow night unless you have explicitly alerted on the absence of data.</p>



<h3 class="wp-block-heading">How often should synthetic checks run?</h3>



<p class="wp-block-paragraph">Work backwards from how fast you need to detect a problem, then account for the consecutive failures you require before alerting. A one-minute interval with a three-failure threshold means roughly three minutes to detection. Cheap HTTP checks can run frequently; browser journeys usually run every five to fifteen minutes because they cost far more per execution.</p>



<h3 class="wp-block-heading">Does RUM slow down the site it&#8217;s measuring?</h3>



<p class="wp-block-paragraph">Slightly, and it&#8217;s a real trade-off rather than a rounding error. A minimal web vitals collector adds very little; a full session-replay agent is a much heavier thing to load. Load the agent asynchronously, sample sessions rather than capturing every one, and measure the agent&#8217;s own cost before you decide it&#8217;s free.</p>



<h3 class="wp-block-heading">Why does my synthetic check pass while PageSpeed Insights fails?</h3>



<p class="wp-block-paragraph">They&#8217;re measuring different things from different places. A synthetic HTTP probe checks that a response came back; PageSpeed Insights reports both a throttled lab simulation and field data from real Chrome users. Neither is wrong. If the field section fails, real users on slower devices are having a worse time than your probe is.</p>



<h3 class="wp-block-heading">Do I need both if I run a small brochure site?</h3>



<p class="wp-block-paragraph">Usually not. A low-traffic site gets almost nothing out of RUM because there aren&#8217;t enough sessions to make a percentile meaningful. Run solid external uptime and certificate checks, look at Search Console&#8217;s Core Web Vitals report for whatever field data exists, and add RUM when traffic justifies it.</p>



<h3 class="wp-block-heading">Can I self-host all of this?</h3>



<p class="wp-block-paragraph">Yes. Blackbox exporter plus Prometheus and Alertmanager covers synthetic; Grafana Faro with a collector you run covers RUM. The catch is that self-hosted probes lose the one property that makes external monitoring valuable, which is independence. Run at least one check from a network you don&#8217;t control, even if everything else is yours.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">Synthetic vs real user monitoring isn&#8217;t a choice between two products that do the same job. It&#8217;s a choice about which blind spot you&#8217;re willing to live with, and the answer to that changes as your traffic changes.</p>



<p class="wp-block-paragraph">If you take one thing away: a quiet dashboard is not evidence of health. Synthetic monitoring goes quiet when you never wrote the check. RUM goes quiet when the site is down. Both silences look exactly like everything being fine, and both need an explicit alert on missing data before you can trust the green.</p>



<h2 class="wp-block-heading">Need help getting your monitoring to match reality?</h2>



<p class="wp-block-paragraph">This is a good chunk of what I do. If your uptime page says one thing and your users say another, I can help with:</p>



<ul class="wp-block-list">
<li>Setting up external synthetic checks with sensible thresholds, so alerts fire on real failures and stop firing on network noise</li>

<li>Blackbox exporter, Prometheus and Alertmanager configuration, including TLS expiry and DNS resolution alerting</li>

<li>Scripting the two or three user journeys that actually earn money, and keeping them in version control next to the app</li>

<li>Adding real user monitoring with Grafana Faro or a hosted RUM platform, sized so the agent doesn&#8217;t become the performance problem</li>

<li>Building Grafana dashboards that put availability and Core Web Vitals side by side with consistent labels, so you can correlate them during an incident</li>

<li>Auditing existing monitoring for coverage gaps, missing-data alerts, and checks that pass for the wrong reasons</li>
</ul>



<p class="wp-block-paragraph">Send me a screenshot of your current alert rules, a scrape config, or just the endpoint that keeps going green while people complain, and I&#8217;ll tell you what it&#8217;s missing.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/synthetic-vs-real-user-monitoring/">Synthetic vs Real User Monitoring: What Your Green Dashboard Isn&#8217;t Telling You</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Building a Data Pipeline Operations Dashboard That Catches Silent Failures</title>
		<link>https://john-nessime.com/blog/devops/data-pipeline-operations-dashboard/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 15 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[Apache Airflow]]></category>
		<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Data Freshness]]></category>
		<category><![CDATA[Data Lineage]]></category>
		<category><![CDATA[Data Observability]]></category>
		<category><![CDATA[Data Quality]]></category>
		<category><![CDATA[Data Warehouse]]></category>
		<category><![CDATA[dbt]]></category>
		<category><![CDATA[ETL]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[OpenTelemetry]]></category>
		<category><![CDATA[Orchestration]]></category>
		<category><![CDATA[Pipeline Design]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[PromQL]]></category>
		<category><![CDATA[Pushgateway]]></category>
		<category><![CDATA[Schema Drift]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[StatsD]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=227</guid>

					<description><![CDATA[<p>Green DAGs and wrong numbers are the same morning. This is how to build a data pipeline operations dashboard around the four questions that actually matter, where each signal comes from, and how to turn the panels into alerts that fire before your users notice.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/data-pipeline-operations-dashboard/">Building a Data Pipeline Operations Dashboard That Catches Silent Failures</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The message usually lands around nine in the morning, from someone in finance or ops: &#8220;Is the revenue dashboard broken, or is it just late?&#8221;</p>



<p class="wp-block-paragraph">So you open the orchestrator. Every task is green. The run started on schedule, finished inside its usual window, exit code zero across the board. Nothing paged. And the numbers are still wrong.</p>



<p class="wp-block-paragraph">That gap is the whole reason to build a data pipeline operations dashboard. Not to watch DAGs go green, which the orchestrator UI already does perfectly well, but to answer the questions the orchestrator structurally cannot answer. This post covers the four signals worth putting on a screen, where each one actually comes from, how to wire them into Prometheus and Grafana without inventing a metrics platform, and how to turn the panels into alerts that fire before a human notices.</p>



<h2 class="wp-block-heading">Why green pipelines still ship bad data</h2>



<p class="wp-block-paragraph">An orchestrator tracks process outcomes. Did the Python exit cleanly, did the SQL statement return without an error, did the container terminate zero. That is a real signal and you should keep it. It is just answering a different question than the one your users care about.</p>



<p class="wp-block-paragraph">Consider what a perfectly successful run looks like when the upstream source has quietly changed:</p>



<ul class="wp-block-list">
<li>An API starts paginating differently and your extractor pulls the first page only. It ran fine. It loaded 500 rows instead of 500,000.</li>
<li>A source column gets renamed and your transform selects it with a <code>coalesce</code> default. It ran fine. Every row now says &#8220;unknown&#8221;.</li>
<li>A partition arrives late, so the incremental filter finds nothing to process. It ran fine. It did nothing.</li>
<li>An upstream team backfills, and your dedupe key no longer holds. It ran fine. Everything is doubled.</li>
</ul>



<p class="wp-block-paragraph">None of those raise an exception. All four are visible in under a second if you are tracking row counts and freshness at the table level. This is the failure family that eats the most incident time, because the clock does not start when the pipeline breaks. It starts when a human notices, which can be days later, and by then the bad data has been copied into reports, cached in a BI extract, and quoted in a meeting.</p>



<h2 class="wp-block-heading">The four questions a data pipeline operations dashboard has to answer</h2>



<p class="wp-block-paragraph">Resist the instinct to start from what your tools can emit. Start from the questions you ask during an incident, in the order you ask them. Everything that does not answer one of these is a second-page panel.</p>



<h3 class="wp-block-heading">Did it run at all?</h3>



<p class="wp-block-paragraph">Sounds trivial, and it is the one most homegrown dashboards get wrong. A panel showing &#8220;last run: success&#8221; does not distinguish between a pipeline that succeeded ten minutes ago and one that succeeded eleven days ago and has not been scheduled since. You want time since last successful completion, as a number that grows, not a status pill that sits there looking calm.</p>



<h3 class="wp-block-heading">Did it finish in time?</h3>



<p class="wp-block-paragraph">Duration matters less than duration against a deadline. A load that takes forty minutes is fine if the report is read at 9am and the run starts at 6am. The same forty minutes is an incident if someone moved the schedule to 8:40. Put the deadline on the panel as a threshold line, not in a runbook. Trending duration also catches slow decay: a stage that creeps from five minutes to fifty usually means data growth, a bad query plan, or resource contention, and it will breach your window eventually.</p>



<h3 class="wp-block-heading">Did the right amount of data arrive?</h3>



<p class="wp-block-paragraph">Rows in versus rows out, per stage. This is the highest value panel per unit of effort on the entire dashboard. A drop from a million rows to twenty thousand is not subtle once you plot it, but it is completely invisible in an exit code. Compare against the same weekday from the previous week rather than against yesterday, because most business data has a weekly shape and Monday-versus-Sunday comparisons generate noise you will learn to ignore.</p>



<h3 class="wp-block-heading">Is the data still the shape you expect?</h3>



<p class="wp-block-paragraph">Null rates, distinct counts on key columns, and schema changes. This is the panel almost nobody builds, and it is where the expensive failures live. A gradual rise in null rate on a joining key means an upstream system is degrading and your joins are silently dropping rows. You do not need statistical anomaly detection to catch that. You need the number plotted over time next to the others.</p>



<p class="wp-block-paragraph">There is a fifth question that belongs on a different surface: what breaks downstream when this table is wrong. That is lineage, and it answers blast radius rather than health. Keep it one click away, not on the operations screen.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Where each signal actually comes from</h2>



<p class="wp-block-paragraph">Three sources, three different collection mechanisms. This is the part that takes an afternoon rather than a sprint, provided you do not try to build a unified agent.</p>



<h3 class="wp-block-heading">The orchestrator, for run and timing signals</h3>



<p class="wp-block-paragraph">Airflow has emitted StatsD metrics for a long time, and that path is still the one most deployments run. Airflow speaks StatsD, so you put a <code>statsd_exporter</code> in front of it to translate into Prometheus format. Enable it in the metrics section of your config, or with the equivalent <code>AIRFLOW__METRICS__</code> environment variables if you are on containers:</p>



<pre class="wp-block-code"><code>[metrics]
statsd_on = True
statsd_host = statsd-exporter
statsd_port = 9125
statsd_prefix = airflow
metrics_allow_list = scheduler,executor,dagrun,pool,triggerer</code></pre>



<p class="wp-block-paragraph">Two things worth knowing before you roll this out. First, <code>metrics_allow_list</code> is not optional at any real scale. Airflow emits per-DAG and per-task metric names, so without a prefix filter your cardinality grows with every DAG anyone adds, and you find out when the time series database starts struggling. Second, Airflow expects the StatsD destination to be resolvable at startup, so a scheduler that boots before the exporter can fail to come up. Order your dependencies accordingly.</p>



<p class="wp-block-paragraph">StatsD has no concept of labels, so the exporter has to reconstruct them from the dotted metric name. That mapping file is the fiddly part:</p>



<pre class="wp-block-code"><code>mappings:
  - match: "*.dag.*.*.duration"
    match_metric_type: observer
    name: "airflow_task_duration"
    labels:
      airflow_id: "$1"
      dag_id: "$2"
      task_id: "$3"</code></pre>



<p class="wp-block-paragraph">The wildcards map positionally onto <code>$1</code>, <code>$2</code>, <code>$3</code>, and <code>match_metric_type</code> has to match what Airflow actually sends, timer versus counter versus gauge. Get that wrong and the metric silently never appears at the exporter, which is a genuinely annoying half hour of debugging. Check the exporter&#8217;s own metrics endpoint before you go looking at Prometheus, because if it is not there it never left Airflow.</p>



<p class="wp-block-paragraph">Airflow also has a native OpenTelemetry path for both metrics and traces, which skips the StatsD translation entirely and gives you real labels rather than reconstructed ones:</p>



<pre class="wp-block-code"><code>[metrics]
otel_on = True
otel_host = otel-collector
otel_port = 4318
otel_prefix = airflow
otel_interval_milliseconds = 30000</code></pre>



<p class="wp-block-paragraph">The port there is the collector&#8217;s OTLP HTTP receiver, and you need an OpenTelemetry Collector or compatible endpoint in the path. If you are standing up a new stack, this is the one I would reach for, because the label problem disappears and traces give you per-task spans under a DAG run parent span, which is worth a lot during a slow-pipeline investigation. If you already have working StatsD mappings and dashboards built on them, migrating is real work for a modest gain, and the honest answer is that it can wait. Metric names do shift between Airflow major versions either way, so pin your dashboard queries to the metrics reference for the version you actually run.</p>



<p class="wp-block-paragraph">Managed Airflow changes the plumbing but not the shape. On Amazon MWAA the metrics land in CloudWatch instead, and Google Cloud Composer and Astronomer each have their own export path. The four questions do not change.</p>



<h3 class="wp-block-heading">The transformation layer, for data-level signals</h3>



<p class="wp-block-paragraph">If you use dbt, most of what you need is already being written to disk and thrown away. Every invocation produces <code>run_results.json</code> in the target directory, containing per-node status and timing, and <code>dbt source freshness</code> produces its own <code>sources.json</code>. The <code>manifest.json</code> holds the dependency graph, which is your lineage.</p>



<p class="wp-block-paragraph">The cheapest useful thing you can do is parse the failures out at the end of the run:</p>



<pre class="wp-block-code"><code>jq -r '.results[]
  | select(.status != "success")
  | [.unique_id, .status, (.execution_time | tostring)]
  | @tsv' target/run_results.json</code></pre>



<p class="wp-block-paragraph">One trap if you run dbt in ephemeral Kubernetes pods: the target directory dies with the pod. Upload the artifacts to object storage as the last step inside the same task that ran dbt, not as a downstream task, or you will find your history is empty exactly when you want it.</p>



<p class="wp-block-paragraph">Elementary is the low-friction option here. It installs as a dbt package with an on-run-end hook that loads the artifacts into your warehouse as ordinary tables, which means your dashboard can query test results and model timings in SQL alongside everything else. Great Expectations and Soda cover the same validation ground with more expressive checks and more setup. The commercial platforms, Monte Carlo and the vendors around it, add automated anomaly detection and column-level lineage across the whole stack, and the genuine case for them is that they find the checks you did not think to write. The trade-off is that you are paying per monitored table for statistical inference, and on a stack of twenty tables that inference is not telling you much that a row-count panel and a null-rate panel would not.</p>



<p class="wp-block-paragraph">Without dbt, you write the check yourself and it is still short. One query per critical table, run on a schedule:</p>



<pre class="wp-block-code"><code>SELECT
  COUNT(*)                                        AS row_count,
  MAX(updated_at)                                 AS last_event,
  SUM(CASE WHEN customer_id IS NULL THEN 1 ELSE 0 END) AS null_keys
FROM analytics.orders
WHERE loaded_at &gt;= CURRENT_DATE - 1;</code></pre>



<h3 class="wp-block-heading">Batch jobs, and the Pushgateway caveat</h3>



<p class="wp-block-paragraph">Prometheus pulls. Batch jobs finish and vanish long before a scrape interval comes round, so there is nothing to pull from. The Pushgateway exists for exactly this: short-lived service-level jobs push their results to it, and Prometheus scrapes the gateway instead.</p>



<pre class="wp-block-code"><code>cat &lt;&lt;EOF | curl --data-binary @- 
  http://pushgateway:9091/metrics/job/orders_sync
# TYPE pipeline_last_success_timestamp_seconds gauge
pipeline_last_success_timestamp_seconds $(date +%s)
# TYPE pipeline_rows_loaded gauge
pipeline_rows_loaded ${ROWS}
EOF</code></pre>



<p class="wp-block-paragraph">Now the caveat, which is the single most common way a pipeline dashboard lies to you. The Pushgateway is a cache, not an aggregator, and it never forgets. Push a success metric, then have the job stop running entirely, and that success sits there being scraped forever. Your dashboard stays green because the last known value is green, and the fact that nothing has updated it in six days is not something a status panel can see.</p>



<p class="wp-block-paragraph">Two defences, and you want both. First, always push a timestamp gauge rather than a boolean status, so staleness is arithmetic rather than inference. Second, alert on absence as well as on value, because the Pushgateway also exposes <code>push_time_seconds</code> per group, which tells you when anything last arrived regardless of what it said. Prometheus&#8217;s usual safety net, the <code>up</code> metric going to zero when a target disappears, does not apply here: Prometheus is scraping the gateway successfully, and the gateway is happily serving stale data.</p>



<p class="wp-block-paragraph">Also keep instance identifiers out of the grouping labels. Push per service, not per worker, or you accumulate a permanent time series for every container that ever ran.</p>



<h2 class="wp-block-heading">Laying out the dashboard so it works during an incident</h2>



<p class="wp-block-paragraph">The layout constraint is that someone is reading this at 7am on a phone with a stakeholder waiting. Three rows, top to bottom, in the order you need them:</p>



<ol class="wp-block-list">
<li><strong>Freshness across everything.</strong> One panel, one row per critical table, showing time since last successful load against its threshold. If this row is clean, the pipelines are fine and the problem is elsewhere. That is a genuinely useful thing to establish in five seconds.</li>
<li><strong>Volume and duration per pipeline.</strong> Row counts with a week-over-week comparison, and stage duration with the deadline drawn on. This is where you localise a problem to a specific pipeline and stage.</li>
<li><strong>Data quality detail.</strong> Null rates, failed test counts, schema change events. This is where you work out what actually went wrong once you know where.</li>
</ol>



<p class="wp-block-paragraph">The freshness query in PromQL, given the timestamp gauge above:</p>



<pre class="wp-block-code"><code>time() - max by (job) (pipeline_last_success_timestamp_seconds)</code></pre>



<p class="wp-block-paragraph">Grafana renders that nicely as a table with per-row thresholds, which is what makes the top row scannable. If you are self-hosting, Prometheus plus Grafana on a modest VPS from Hetzner, InterServer or DigitalOcean handles a surprising amount of pipeline metrics, because pipeline telemetry is low-volume compared to application metrics. Grafana Cloud&#8217;s free tier is a reasonable alternative if you do not want to own the storage, and Datadog or New Relic make sense if your organisation is already paying for one of them and you would rather have one pane than a better one.</p>



<p class="wp-block-paragraph">Keep it to one screen. Every panel you add past that dilutes the ones that matter, and a dashboard nobody can read at a glance is a dashboard nobody opens.</p>



<h2 class="wp-block-heading">Turning panels into alerts</h2>



<p class="wp-block-paragraph">A dashboard that requires someone to look at it is a dashboard that finds problems the morning after. The freshness panel converts into an alert almost directly:</p>



<pre class="wp-block-code"><code>groups:
  - name: pipelines
    rules:
      - alert: PipelineStale
        expr: time() - max by (job) (pipeline_last_success_timestamp_seconds) &gt; 5400
        for: 10m
        labels:
          severity: page
        annotations:
          summary: "{{ $labels.job }} has not completed successfully in over 90 minutes"

      - alert: PipelineMetricsMissing
        expr: absent(pipeline_last_success_timestamp_seconds{job="orders_sync"})
        for: 30m
        labels:
          severity: page
        annotations:
          summary: "No metrics received from orders_sync"</code></pre>



<p class="wp-block-paragraph">The first catches a pipeline that is late or broken. The second catches the one that stopped reporting entirely, which the first cannot see, because an expression over a series that no longer exists returns nothing and an empty result does not fire. That pairing is the whole trick, and it applies to every push-based metric you collect.</p>



<p class="wp-block-paragraph">Set the threshold from the deadline the data has to meet, not from how long the job usually takes. Ninety minutes is not &#8220;the run takes an hour so give it some room&#8221;. It should be the point past which someone downstream is materially affected.</p>



<h2 class="wp-block-heading">Troubleshooting a dashboard that shows nothing useful</h2>



<p class="wp-block-paragraph"><strong>The metric never appears in Prometheus.</strong> Work backwards along the path, one hop at a time. Curl the statsd_exporter&#8217;s own metrics endpoint first. If the metric is not there, it never left the orchestrator, so the problem is the config or an allow-list that filtered it out. If it is there but not in Prometheus, the problem is your scrape config. Do not start in Grafana.</p>



<p class="wp-block-paragraph"><strong>The metric appears with no labels, or with the DAG name baked into the metric name.</strong> Your statsd_exporter mapping did not match. Check the wildcard count against the actual dotted name, and check <code>match_metric_type</code>, which is the usual culprit.</p>



<p class="wp-block-paragraph"><strong>Everything is green but the data is old.</strong> Classic Pushgateway staleness. Query <code>push_time_seconds</code> for the affected group and compare it to now. If it has not moved, the job is not running and you are looking at a cached value.</p>



<p class="wp-block-paragraph"><strong>Volume alerts fire every Monday.</strong> You are comparing against the previous day on data with a weekly cycle. Compare against the same day last week using an offset, or exclude weekends from the baseline.</p>



<p class="wp-block-paragraph"><strong>Gaps in the duration graph.</strong> If the metric is a timer sent only on completion, a run that is still going or that failed early sends nothing. That is correct behaviour, and it is why liveness needs its own panel rather than being inferred from the duration series.</p>



<p class="wp-block-paragraph"><strong>Prometheus memory climbing after rollout.</strong> Cardinality. Per-DAG and per-task metrics multiply, and any label carrying a run identifier, timestamp or UUID creates a permanent new series. Tighten the allow list and drop the offending labels.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Treating job success as data correctness. This is the entire problem, and every other mistake is a variation of it.</li>
<li>Pushing a boolean status instead of a timestamp, so staleness becomes invisible.</li>
<li>Alerting on threshold breaches but never on metric absence.</li>
<li>Building the dashboard before agreeing what &#8220;healthy&#8221; means for each table with the people who consume it.</li>
<li>Instrumenting every pipeline at once instead of the three that matter, then abandoning the project when the noise gets unmanageable.</li>
<li>Putting instance or pod identifiers into Pushgateway grouping labels and accumulating dead series forever.</li>
<li>Alerting on things nobody will act on at 3am. An alert that gets acknowledged and ignored twice has trained your team to ignore it permanently.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Baseline before you threshold. Collect for two or three weeks and look at the actual shape of the data before deciding what abnormal means.</li>
<li>Start with freshness on your three most critical tables. It catches more real problems per line of code than anything else you can instrument.</li>
<li>Define freshness thresholds from the consumer&#8217;s deadline, and write the consumer&#8217;s name next to the threshold so the number has an owner.</li>
<li>Version dashboards and alert rules in Git alongside the pipelines. A dashboard edited live in the UI is a dashboard that will be silently different next quarter.</li>
<li>Emit row counts at stage boundaries as a habit, not as a special case. In versus out at each hop is what turns &#8220;the numbers are wrong&#8221; into &#8220;stage three drops rows&#8221;.</li>
<li>Review alerts monthly and delete the ones that never fired usefully. Alert rules are code and they rot the same way.</li>
<li>Keep lineage accessible but off the main screen. It answers a different question and it competes for attention with the ones you need first.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">What is the difference between pipeline monitoring and data observability?</h3>



<p class="wp-block-paragraph">Monitoring watches the process: did the job run, did it succeed, how long did it take. Observability watches the data the process produced: is it fresh, is there the right amount, has its shape changed. You need both, and the second is the one most teams are missing when they say their dashboards did not catch something.</p>



<h3 class="wp-block-heading">Do I need a commercial data observability platform?</h3>



<p class="wp-block-paragraph">Not to start. Freshness, volume and null-rate checks on your critical tables cover most real incidents and cost an afternoon. Commercial platforms earn their keep when you have hundreds of tables, several teams changing them independently, and nobody with time to hand-write checks. The honest signal that you have outgrown the homegrown version is when maintaining the checks becomes its own recurring task.</p>



<h3 class="wp-block-heading">Should I use StatsD or OpenTelemetry for Airflow metrics?</h3>



<p class="wp-block-paragraph">New stack, OpenTelemetry: you get real labels instead of reconstructing them from dotted names, and traces come along with it. Existing stack with working StatsD mappings, stay put until you have another reason to touch it. Metric coverage and naming differ between the two paths and between Airflow versions, so verify against the metrics reference for your version rather than copying a dashboard from a blog post.</p>



<h3 class="wp-block-heading">How do I monitor a pipeline that only runs weekly?</h3>



<p class="wp-block-paragraph">Same timestamp gauge, much larger threshold, and lean harder on the absence alert. Infrequent pipelines are where Pushgateway staleness does the most damage, because the last successful push can sit there for a fortnight looking entirely healthy. Check <code>push_time_seconds</code> for those groups specifically.</p>



<h3 class="wp-block-heading">What should a data pipeline operations dashboard show first?</h3>



<p class="wp-block-paragraph">Time since last successful load, per critical table, with its threshold visible. That one panel answers the question you will be asked most often, and when it is clean it rules out the entire pipeline layer in a glance.</p>



<h3 class="wp-block-heading">How many alerts is the right number?</h3>



<p class="wp-block-paragraph">Few enough that every page gets investigated. If someone can name an alert they routinely dismiss without looking, you have too many, and the cost is not the noise itself but the habit it builds. Start with freshness and absence per critical pipeline, add volume, and only go further when a real incident shows you a gap.</p>



<h3 class="wp-block-heading">Can I build this without an orchestrator like Airflow?</h3>



<p class="wp-block-paragraph">Yes. Cron jobs, Step Functions, GitHub Actions and shell scripts can all push a timestamp and a row count to a Pushgateway in three lines. The orchestrator gives you the run and timing signals for free, but it is the data-level signals that do the heavy lifting, and those come from querying the warehouse regardless of what triggered the load.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing to take away</h2>



<p class="wp-block-paragraph">A data pipeline operations dashboard is not a nicer view of your orchestrator. It exists because the orchestrator can only tell you that a process finished, and the question you are actually being asked is whether the data is right.</p>



<p class="wp-block-paragraph">If you build one panel this week, build time since last successful load, sourced from a timestamp gauge, alerted on both threshold and absence. It costs an afternoon, it catches the failure mode that hides longest, and it turns &#8220;is the dashboard broken or just late?&#8221; into a question you can answer before anyone has to ask it.</p>



<h2 class="wp-block-heading">Need help building or fixing this?</h2>



<p class="wp-block-paragraph">I work with teams on pipeline observability and the plumbing underneath it. Things I regularly help with:</p>



<ul class="wp-block-list">
<li>Designing freshness, volume and quality checks for your critical tables, with thresholds derived from real consumer deadlines rather than guesses</li>
<li>Wiring Airflow into Prometheus and Grafana, including the statsd_exporter mappings and the cardinality controls that keep it affordable</li>
<li>Migrating an existing StatsD setup to the OpenTelemetry path without losing your dashboard history</li>
<li>Getting dbt artifacts out of ephemeral pods and into somewhere you can query them, with Elementary or with plain object storage</li>
<li>Auditing an existing dashboard and alert set to find what is stale, what will never fire, and what is quietly training your team to ignore pages</li>
<li>Standing up a self-hosted Prometheus and Grafana stack on a VPS, or sizing a managed alternative if owning the storage is not worth it to you</li>
</ul>



<p class="wp-block-paragraph">If you have a dashboard that went green through an incident, send me the panel queries and the alert rules and I will tell you where the gap is.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/data-pipeline-operations-dashboard/">Building a Data Pipeline Operations Dashboard That Catches Silent Failures</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>One Label Too Many: Centralized Logging With Loki Without Wrecking It</title>
		<link>https://john-nessime.com/blog/devops/centralized-logging-loki/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 01 Aug 2026 09:24:17 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Grafana Alloy]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Logging]]></category>
		<category><![CDATA[LogQL]]></category>
		<category><![CDATA[Logs]]></category>
		<category><![CDATA[Loki]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=68</guid>

					<description><![CDATA[<p>Loki was fast until someone added one more label. Now queries time out during incidents and the index no longer fits in memory. Here's the model you have to internalise, what replaced Promtail, and how to keep labels, retention and storage costs under control.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/centralized-logging-loki/">One Label Too Many: Centralized Logging With Loki Without Wrecking It</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The alert fires at eleven at night. You open Grafana, pick the right time range, type a query, and watch the spinner. Forty seconds later it times out. You narrow the range to five minutes and it comes back, eventually. The one tool you need during an incident is the one that has become unusable.</p>



<p class="wp-block-paragraph">Nothing about the Loki config changed. What changed is that three weeks ago somebody added <code>request_id</code> as a label because it seemed helpful, and Loki has been quietly building millions of separate streams ever since. The index that used to fit comfortably in memory does not. Every query now has to open thousands of tiny chunks instead of a handful of large ones.</p>



<p class="wp-block-paragraph">This is the failure mode that defines <strong>centralized logging with Loki</strong>, and it is almost entirely self-inflicted. Loki is cheap and fast when you use it the way it was designed and miserable when you bring habits from Elasticsearch. Nothing warns you at the moment you make the mistake. The bill comes weeks later, during an incident.</p>



<p class="wp-block-paragraph">This covers the model you have to internalise, how to choose labels, what replaced Promtail (it is gone, and every tutorial older than this year is wrong about it), the LogQL patterns worth knowing, and how to keep retention and storage costs under control.</p>



<h2 class="wp-block-heading">Loki is not Elasticsearch, and that is the entire design</h2>



<p class="wp-block-paragraph">Elasticsearch builds a full inverted index over every token in every log line. Powerful, and the index often ends up as large as the logs themselves. That is where the cost comes from.</p>



<p class="wp-block-paragraph">Loki indexes only labels. The log content is compressed into chunks and pushed to object storage, untouched and unindexed. A query does two things: it uses the index to select which streams to read, then it brute-force scans the contents of those chunks in parallel.</p>



<p class="wp-block-paragraph">The consequence is a rule worth writing on a wall: <strong>labels select, filters search.</strong> Labels exist to narrow down which chunks get opened. Everything else is a filter applied at query time. Scanning is genuinely fast because it is embarrassingly parallel, so filtering on content is not the expensive operation people assume.</p>



<p class="wp-block-paragraph">What is expensive is streams. A stream is one unique combination of label values, and each gets its own chunks. Do the arithmetic before you add a label:</p>



<ul class="wp-block-list">
<li>8 jobs, 3 environments, 12 hosts gives you 288 streams. Comfortable.</li>
<li>Add <code>request_id</code>, and you multiply that by however many requests you serve. The index explodes, chunks get flushed while nearly empty, and object storage fills with millions of tiny files that queries must open individually.</li>
</ul>



<p class="wp-block-paragraph">Ingesters also hold every active stream in memory. Cardinality does not just make queries slow; it is how Loki gets OOM-killed.</p>



<h2 class="wp-block-heading">Choosing labels</h2>



<p class="wp-block-paragraph">A label earns its place if it satisfies all three: the set of values is bounded and small, you know the values in advance, and you would actually use it to narrow a search.</p>



<ul class="wp-block-list">
<li><strong>Good:</strong> <code>job</code>, <code>service</code>, <code>env</code>, <code>host</code>, <code>namespace</code>, <code>level</code>.</li>
<li><strong>Bad:</strong> <code>request_id</code>, <code>trace_id</code>, <code>user_id</code>, <code>session_id</code>, full URL paths, IP addresses, timestamps, durations, anything derived from the log content itself.</li>
</ul>



<p class="wp-block-paragraph">Two subtler traps. <code>instance</code> including an ephemeral port is unbounded. And in container environments, pod names churn constantly, so labelling by pod name gives you a new stream every deployment forever.</p>



<p class="wp-block-paragraph">Aim to keep the total under a few thousand active streams. Check rather than guess, using <code>logcli</code>, which reports cardinality per label:</p>



<pre class="wp-block-code"><code># Which of your labels is quietly out of control?
logcli series '{}' --analyze-labels</code></pre>



<p class="wp-block-paragraph">Run that before you have a problem. It is the single most useful diagnostic here and almost nobody knows about it.</p>



<h3 class="wp-block-heading">The escape hatch: structured metadata</h3>



<p class="wp-block-paragraph">&#8220;Never label a trace ID&#8221; is correct but unsatisfying, because sometimes you genuinely need to find every line for one trace. Loki 3 answers this with structured metadata: key-value pairs attached to a log line, stored alongside it, <em>not</em> in the index and not creating streams.</p>



<pre class="wp-block-code"><code>limits_config:
  # Off by default on older configs. Turn it on before your
  # agent starts sending structured metadata, or the writes
  # get rejected and you spend an hour puzzling over it.
  allow_structured_metadata: true</code></pre>



<p class="wp-block-paragraph">Query it with the same label-filter syntax, and it costs you nothing in cardinality:</p>



<pre class="wp-block-code"><code>{job="api", env="production"} | trace_id="9f2c1b4e"</code></pre>



<p class="wp-block-paragraph">So the decision becomes three-way rather than two-way: bounded and used for selection becomes a label, high-cardinality but frequently searched becomes structured metadata, and everything else stays in the line and gets parsed at query time.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Promtail is gone. Use Alloy.</h2>



<p class="wp-block-paragraph">This one has a date and it matters, because it invalidates most of the Loki material on the internet.</p>



<p class="wp-block-paragraph">Promtail, the agent every Loki tutorial tells you to install, was deprecated in February 2025 and reached end of life on 2 March 2026. No security patches, no bug fixes, no updates. Grafana Alloy, their distribution of the OpenTelemetry Collector, is the replacement, and it handles logs, metrics, traces and profiles in one binary instead of running a separate agent per signal.</p>



<p class="wp-block-paragraph">If you already run Promtail, there is a conversion tool rather than a rewrite:</p>



<pre class="wp-block-code"><code># Converts an existing Promtail config to Alloy syntax.
# Read the output before deploying it; the converter flags
# anything it could not translate cleanly.
alloy convert --source-format=promtail --output=config.alloy promtail.yaml</code></pre>



<p class="wp-block-paragraph">Alloy configuration is a component graph rather than a YAML pipeline. Components reference each other&#8217;s outputs, which reads oddly at first and then makes more sense than nested YAML stages:</p>



<pre class="wp-block-code"><code>// Find the files, and attach the labels that will select them later.
// Note what is NOT here: nothing derived from log content.
local.file_match "system" {
  path_targets = [{
    __path__ = "/var/log/syslog",
    job      = "syslog",
    host     = constants.hostname,
    env      = "production",
  }]
}

loki.source.file "system" {
  targets    = local.file_match.system.targets
  forward_to = [loki.write.default.receiver]
}

loki.write "default" {
  endpoint {
    url = "http://loki:3100/loki/api/v1/push"
  }
}</code></pre>



<p class="wp-block-paragraph">Between source and write you can insert a <code>loki.process</code> component to parse lines, drop noise, and promote fields into structured metadata. Do the dropping here rather than at the Loki end: logs you never ship cost nothing to store, index or query, and debug-level chatter from a busy service is usually the single largest slice of ingest.</p>



<p class="wp-block-paragraph">For Docker hosts, Alloy has components that discover containers and read their logs directly, which is tidier than tailing the JSON files under <code>/var/lib/docker</code> and surviving a logging-driver change.</p>



<h2 class="wp-block-heading">Deployment shape and storage</h2>



<p class="wp-block-paragraph">Loki ships as one binary that runs in different modes depending on the <code>-target</code> flag.</p>



<ul class="wp-block-list">
<li><strong>Monolithic.</strong> Everything in one process. Right for a single VPS, a homelab, or anything up to a modest daily volume. Do not feel bad about this; a lot of people run microservices mode for a workload one process would handle.</li>
<li><strong>Simple scalable.</strong> Splits into read, write and backend targets behind a load balancer. The sensible next step, and where most self-hosted setups should stop.</li>
<li><strong>Microservices.</strong> Every component separately. Genuinely necessary at large scale and a substantial operational burden below it.</li>
</ul>



<p class="wp-block-paragraph">For storage, use TSDB with schema v13. Older guides show <code>boltdb-shipper</code>; that is the previous generation. Chunks belong in object storage in anything you care about, because filesystem storage means one disk, one node, and no redundancy.</p>



<pre class="wp-block-code"><code>schema_config:
  configs:
    # Schema changes are additive. Add a new entry with a future
    # 'from' date rather than editing the existing one, or Loki
    # cannot read the data it already wrote.
    - from: 2024-01-01
      store: tsdb
      object_store: s3
      schema: v13
      index:
        prefix: index_
        period: 24h</code></pre>



<p class="wp-block-paragraph">That &#8220;additive&#8221; note is worth taking seriously. Editing an existing schema entry in place is one of the few ways to make historical logs unreadable.</p>



<p class="wp-block-paragraph">Any S3-compatible store works, which is where the cost advantage lives. Chunks are compressed and write-once, so cheaper tiers suit them well: S3 itself, or MinIO if you want it self-hosted, or one of the cheaper compatible providers like Backblaze B2 or Cloudflare R2. Watch egress pricing rather than storage pricing, since queries read chunks back.</p>



<h2 class="wp-block-heading">LogQL worth actually knowing</h2>



<p class="wp-block-paragraph">Every query starts with a stream selector in braces. That part is mandatory and it is what decides how much data gets read.</p>



<pre class="wp-block-code"><code># Order matters for performance:
#   1. select streams (index lookup)
#   2. filter lines (cheap string match, runs on every line)
#   3. parse (expensive, so run it on the smallest set possible)
{job="api", env="production"} |= "timeout" | json | status="500"</code></pre>



<p class="wp-block-paragraph">People routinely write <code>| json</code> before the line filter and then wonder why the query is slow. Parsing every line in the range to discard most of them is exactly the wrong order.</p>



<p class="wp-block-paragraph">Loki also turns logs into metrics, which is where it earns its place next to Prometheus:</p>



<pre class="wp-block-code"><code># Error rate per service, graphable and alertable.
sum by (job) (rate({env="production"} |= "level=error" [5m]))

# Which streams are eating your ingest budget?
topk(10, sum by (job, host) (count_over_time({env="production"}[1h])))</code></pre>



<p class="wp-block-paragraph">That second one is worth saving as a dashboard panel. Ingest problems announce themselves there long before they show up as a slow query.</p>



<p class="wp-block-paragraph">Loki&#8217;s ruler evaluates the same expressions as alerting rules and sends them to Alertmanager, so &#8220;alert when this service logs more than N errors in five minutes&#8221; needs no extra component if you already run Prometheus.</p>



<h2 class="wp-block-heading">Retention, and why it silently does nothing</h2>



<p class="wp-block-paragraph">Setting <code>retention_period</code> is not enough. Retention is enforced by the compactor, and the compactor does not do it unless you tell it to. Plenty of people set a retention period, watch their bucket keep growing, and never connect the two.</p>



<pre class="wp-block-code"><code>compactor:
  working_directory: /loki/compactor
  # Without this, the compactor compacts the index and
  # deletes nothing. This is the line people miss.
  retention_enabled: true
  delete_request_store: s3

limits_config:
  retention_period: 720h        # 30 days, global default</code></pre>



<p class="wp-block-paragraph">Per-stream retention lets you keep what matters and discard what does not: audit logs for a year, application debug logs for three days. That difference tends to matter more to the bill than any storage tier choice.</p>



<p class="wp-block-paragraph">Deletion is asynchronous. Chunks disappear from object storage some time after the index entries go, so do not panic if the bucket does not shrink immediately.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Troubleshooting</h2>



<h3 class="wp-block-heading">Queries are slow or time out</h3>



<p class="wp-block-paragraph">Cardinality first. Run <code>logcli series '{}' --analyze-labels</code> and look for a label with a suspiciously large value count. Then check query shape: a selector that matches everything, a parser before a line filter, or a time range far wider than needed. Adding query frontend caching helps repeated queries but will not rescue a bad label set.</p>



<h3 class="wp-block-heading">Stream limit exceeded</h3>



<p class="wp-block-paragraph">Loki is telling you the truth. Raising <code>max_global_streams_per_user</code> buys time and makes the underlying problem worse. Find the label that is generating streams and move it to structured metadata or into the line.</p>



<h3 class="wp-block-heading">Rate limit or ingestion errors</h3>



<p class="wp-block-paragraph">Two different limits produce similar-looking rejections: a global ingestion rate, and a per-stream rate. Per-stream limits usually mean one very chatty stream rather than too much traffic overall, and the fix is to split it by a label or drop the noise at the agent.</p>



<h3 class="wp-block-heading">Logs are not appearing at all</h3>



<p class="wp-block-paragraph">Work forward from the agent. Check the agent&#8217;s own logs first, then whether it can reach the push endpoint, then whether the file is actually readable by the agent&#8217;s user. Container log permissions and SELinux catch people constantly. In Grafana, remember the label browser only shows labels seen recently, so an empty dropdown often means nothing has arrived, not that the query is wrong.</p>



<h3 class="wp-block-heading">Logs stop after an agent restart</h3>



<p class="wp-block-paragraph">The position file that tracks how far it read is not persisting. In a container, that means it is not on a mounted volume, so every restart re-reads or skips depending on configuration.</p>



<h3 class="wp-block-heading">Storage keeps growing despite retention</h3>



<p class="wp-block-paragraph">Confirm <code>retention_enabled: true</code> is actually set, that exactly one compactor is running, and that it has write access to the object store. A compactor that cannot delete usually says so in its logs and nowhere else.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Treating labels like Elasticsearch fields and labelling everything.</li>
<li>Promoting a parsed value into a label without checking how many distinct values it has.</li>
<li>Following a tutorial that installs Promtail, which is now end of life.</li>
<li>Writing <code>| json</code> before the line filter.</li>
<li>Setting <code>retention_period</code> without <code>retention_enabled: true</code>.</li>
<li>Running more than one compactor.</li>
<li>Editing an existing <code>schema_config</code> entry instead of adding a new one.</li>
<li>Filesystem storage on a single node for anything you would miss.</li>
<li>Shipping every debug line and then paying to store, index and scan it.</li>
<li>Raising stream limits instead of fixing cardinality.</li>
<li>Running microservices mode for a workload the single binary handles comfortably.</li>
<li>No dashboard showing ingest volume per stream, so growth is invisible until queries break.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Decide labels deliberately: bounded, known in advance, used for selection.</li>
<li>Put high-cardinality but searchable values in structured metadata, not labels.</li>
<li>Run <code>--analyze-labels</code> periodically, not just when something breaks.</li>
<li>Drop noise at the agent, before it costs you anything downstream.</li>
<li>Emit structured logs from your applications so parsers are cheap and reliable.</li>
<li>Start monolithic and move to simple scalable only when you have a reason.</li>
<li>TSDB, schema v13, chunks in object storage.</li>
<li>Turn retention on properly and use per-stream retention to keep costs sane.</li>
<li>Order queries: select, filter, parse.</li>
<li>Alert on log-derived metrics through the ruler rather than eyeballing dashboards.</li>
<li>Graph ingest volume per job so cardinality growth shows up as a trend.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">Is Loki a drop-in replacement for the ELK stack?</h3>



<p class="wp-block-paragraph">No, and expecting it to be is where most disappointment comes from. Loki is dramatically cheaper to run and much better suited to &#8220;show me the logs for this service around this time&#8221;. Elasticsearch is better at ad-hoc full-text search across everything with no idea where to start. Pick based on which of those you do more often.</p>



<h3 class="wp-block-heading">Why is my Loki so slow?</h3>



<p class="wp-block-paragraph">Almost always label cardinality. Run <code>logcli series '{}' --analyze-labels</code> and look for the outlier. The second most common cause is a query that parses before it filters. Hardware is a distant third.</p>



<h3 class="wp-block-heading">Should I still use Promtail?</h3>



<p class="wp-block-paragraph">No. It reached end of life in March 2026 and receives no security patches. Use Grafana Alloy, and run <code>alloy convert</code> against your existing config rather than rewriting it from scratch.</p>



<h3 class="wp-block-heading">How many labels is too many?</h3>



<p class="wp-block-paragraph">The count matters less than the product of their distinct values. Five labels with a handful of values each is fine. Three labels where one has fifty thousand values is not. Multiply before you add.</p>



<h3 class="wp-block-heading">Can I run Loki on a single VPS?</h3>



<p class="wp-block-paragraph">Yes, and for a handful of services it is a perfectly good answer. Monolithic mode alongside Grafana and Prometheus fits on a modest box from any provider. Point chunk storage at an object store rather than local disk so a dead server does not take your history with it.</p>



<h3 class="wp-block-heading">How do I search for a specific request across services?</h3>



<p class="wp-block-paragraph">Attach the trace or request ID as structured metadata at the agent, then filter on it. You get the high-cardinality lookup you wanted without creating a stream per request.</p>



<h3 class="wp-block-heading">Self-host or use a managed service?</h3>



<p class="wp-block-paragraph">Self-hosting a monolithic Loki is genuinely low effort. Self-hosting a highly available multi-tenant Loki is a real ongoing job, and at that point Grafana Cloud or a hosted alternative deserves an honest cost comparison including your time. The break-even is further out than vendors suggest and closer than enthusiasts admit.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing to remember</h2>



<p class="wp-block-paragraph">Labels select, filters search. Every serious problem with centralized logging with Loki traces back to someone treating a label as a search field. Loki will accept it without complaint, and the cost arrives weeks later as a query that will not finish.</p>



<p class="wp-block-paragraph">So before adding any label, multiply out how many streams it creates. If the answer is more than a few thousand, it belongs in structured metadata or in the log line. And check your cardinality on a schedule, because this degrades gradually and you will not notice the day it crosses the line.</p>



<h2 class="wp-block-heading">Need a logging setup that stays fast?</h2>



<p class="wp-block-paragraph">Most Loki installations I get asked to look at were fine on day one and got slower every week. Work I take on:</p>



<ul class="wp-block-list">
<li>Diagnosing a slow Loki: cardinality analysis, query shape review, and a concrete list of labels to move.</li>
<li>Migrating Promtail configurations to Grafana Alloy, including Docker and Kubernetes log collection.</li>
<li>Building a centralized logging stack from scratch: Loki, Alloy, Grafana, object storage, retention and dashboards.</li>
<li>Restructuring labels and introducing structured metadata without losing queryability.</li>
<li>Retention and storage cost work: per-stream policies, agent-side dropping, object storage tiering.</li>
<li>Log-based alerting through the ruler, wired into an existing Prometheus and Alertmanager setup.</li>
</ul>



<p class="wp-block-paragraph">Send me the output of <code>logcli series '{}' --analyze-labels</code> and a query that feels slow, and I will tell you where the problem is.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/centralized-logging-loki/">One Label Too Many: Centralized Logging With Loki Without Wrecking It</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Metrics, Logs and Traces: What Each One Answers</title>
		<link>https://john-nessime.com/blog/devops/metrics-logs-traces/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Fri, 31 Jul 2026 22:54:33 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Logging]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[OpenTelemetry]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Tracing]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=52</guid>

					<description><![CDATA[<p>Metrics tell you that something is wrong. Traces tell you where. Logs tell you why. Use them in that order and each step narrows the search — start with logs and you are grepping a firehose. Here is what each one is actually for, and the field that connects all three.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/metrics-logs-traces/">Metrics, Logs and Traces: What Each One Answers</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Checkout is slow. Somebody says it in Slack, and within a minute three people are looking at three different screens. One is scrolling a dashboard where every panel looks normal. One is grepping logs for the word &#8220;error&#8221; and finding nothing. One is reading a trace for a request that completed in 40ms and wondering why they bothered.</p>



<p class="wp-block-paragraph">Nobody is doing anything wrong. They are just using the wrong tool for the question at hand, and none of them has said out loud what question they are trying to answer.</p>



<p class="wp-block-paragraph">Metrics, logs and traces get called the three pillars of observability, which makes them sound like three views of the same thing. They are not. They answer three different questions, and knowing which question you are asking is most of the skill.</p>



<h2 class="wp-block-heading">The one-line version</h2>



<ul class="wp-block-list">
<li><strong>Metrics</strong> tell you <em>that</em> something is wrong, and when it started.</li>

<li><strong>Traces</strong> tell you <em>where</em> in the system it is wrong.</li>

<li><strong>Logs</strong> tell you <em>why</em> that particular thing went wrong.</li>
</ul>



<p class="wp-block-paragraph">That ordering is also the order you should use them in during an incident. Starting with logs is the most common mistake, and it is why people end up grepping for twenty minutes before discovering the problem was a downstream service that never logged anything at all.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Metrics: numbers over time, and the cost of a label</h2>



<p class="wp-block-paragraph">A metric is a number sampled repeatedly. Requests per second, memory used, queue depth. They are cheap to store because each sample is a timestamp and a value, and they aggregate well — which is exactly what makes them right for dashboards and alerts, and wrong for investigating one specific user&#8217;s problem.</p>



<pre class="wp-block-code"><code># Error rate as a proportion of all requests, per service
sum(rate(http_requests_total{status=~"5.."}[5m])) by (service)
  /
sum(rate(http_requests_total[5m])) by (service)</code></pre>



<p class="wp-block-paragraph">That query is answerable in milliseconds across months of history. No log search comes close.</p>



<h3 class="wp-block-heading">Averages lie, and they lie in a specific direction</h3>



<p class="wp-block-paragraph">An average response time of 200ms is compatible with 95% of requests at 50ms and 5% at three seconds. The people having a terrible time are invisible in the mean, and they are the ones who complain.</p>



<pre class="wp-block-code"><code># p99 latency by endpoint
histogram_quantile(0.99,
  sum(rate(http_request_duration_seconds_bucket[5m])) by (le, endpoint)
)</code></pre>



<p class="wp-block-paragraph">Alert on p95 or p99, look at p50 to understand the typical case, and be aware that averaging percentiles across instances is mathematically meaningless — you have to aggregate the histogram buckets first, which is what the query above does.</p>



<h3 class="wp-block-heading">Cardinality is the thing that breaks Prometheus</h3>



<p class="wp-block-paragraph">Every unique combination of label values creates a separate time series. Add a label with unbounded values and you multiply your storage and memory by the number of distinct values.</p>



<pre class="wp-block-code"><code># Fine. A handful of endpoints, a handful of statuses.
http_requests_total{endpoint="/checkout", status="200"}

# Catastrophic. One time series per user, forever.
http_requests_total{endpoint="/checkout", user_id="8a3f...", request_id="..."}</code></pre>



<p class="wp-block-paragraph">User IDs, request IDs, session tokens, full URLs with parameters, email addresses — none of these belong in a metric label. They belong in logs and traces, which are built for high-cardinality data.</p>



<p class="wp-block-paragraph">To see whether you already have a problem:</p>



<pre class="wp-block-code"><code># Which metric names have the most series?
topk(10, count by (__name__)({__name__=~".+"}))</code></pre>



<p class="wp-block-paragraph">Run that on any Prometheus that has been growing unexpectedly. The answer is usually one metric somebody added a request ID to.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Logs: the detail, once you know where to look</h2>



<p class="wp-block-paragraph">Logs are discrete events with arbitrary detail attached. Their strength is precision — the actual exception, the actual SQL statement, the actual value that was null. Their weakness is volume and cost, since you are storing every event rather than an aggregate.</p>



<h3 class="wp-block-heading">Structured or it did not happen</h3>



<p class="wp-block-paragraph">Compare these two lines for the same event:</p>



<pre class="wp-block-code"><code># Human readable, machine hostile
2026-01-15 14:32:11 ERROR Payment failed for user 8a3f after 3 retries

# Structured: queryable without regex archaeology
{"ts":"2026-01-15T14:32:11Z","level":"error","msg":"payment failed",
 "user_id":"8a3f","retries":3,"provider":"stripe","error_code":"card_declined",
 "trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","duration_ms":1840}</code></pre>



<p class="wp-block-paragraph">With the second, &#8220;how many payments failed with card_declined on stripe in the last hour, grouped by retry count&#8221; is a query. With the first it is a regular expression that will break the next time someone rewords the message.</p>



<h3 class="wp-block-heading">The field that ties everything together</h3>



<p class="wp-block-paragraph">Notice <code>trace_id</code> in that log line. That single field is what turns three separate tools into one workflow. Find a slow trace, take its ID, and pull every log line from every service involved in that exact request.</p>



<p class="wp-block-paragraph">If you do one thing after reading this, make it that: get trace and span IDs into your structured logs. It is usually a few lines in a logging middleware and it changes how debugging feels.</p>



<h3 class="wp-block-heading">Levels, used properly</h3>



<ul class="wp-block-list">
<li><strong>ERROR</strong> — something failed and a human may need to act. If nobody would act on it, it is not an error.</li>

<li><strong>WARN</strong> — recovered, but worth knowing. A retry succeeded, a fallback was used.</li>

<li><strong>INFO</strong> — meaningful state changes. Started, stopped, config loaded, job completed.</li>

<li><strong>DEBUG</strong> — off in production, on when you are actively investigating.</li>
</ul>



<p class="wp-block-paragraph">The failure mode is logging everything at INFO. You get volume, cost, and a signal that nobody reads because it is 99% noise. If your ERROR channel fires constantly and nobody looks, you have trained your team to ignore errors.</p>



<h3 class="wp-block-heading">Sampling, when volume gets expensive</h3>



<p class="wp-block-paragraph">Keep all errors and warnings. Sample the successful path — one in a hundred INFO lines from a healthy endpoint tells you the same thing as all hundred, at a fraction of the cost. Never sample errors. The one you drop is the one you needed.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Traces: where the time actually went</h2>



<p class="wp-block-paragraph">A trace follows one request across every service it touches. Each unit of work is a span, with a start time, duration, and a parent — so the trace becomes a tree showing exactly where the milliseconds went.</p>



<pre class="wp-block-code"><code>POST /checkout                                    1840ms
├─ auth.verify_token                                12ms
├─ cart.get_items                                   34ms
│  └─ SELECT * FROM cart_items WHERE ...            28ms
├─ inventory.check_stock                            41ms
├─ payment.charge                                 1690ms   ← here
│  ├─ HTTP POST api.stripe.com/v1/charges         1680ms
│  └─ db.insert payment_record                       8ms
└─ order.create                                     52ms</code></pre>



<p class="wp-block-paragraph">No amount of dashboard-staring gets you there that fast. The metric told you p99 checkout latency doubled; the trace tells you it is one outbound HTTP call, and now you know whether to look at your code or send an email to a vendor.</p>



<h3 class="wp-block-heading">Traces are also how you find work you did not know about</h3>



<p class="wp-block-paragraph">The classic find is the N+1 query — a trace showing four hundred nearly identical database spans where you expected one. That is invisible in metrics, because four hundred fast queries look healthy in aggregate, and invisible in logs unless you happen to log every query.</p>



<h3 class="wp-block-heading">Context propagation is the part that breaks</h3>



<p class="wp-block-paragraph">Tracing works because each service passes the trace context to the next one, usually in the W3C <code>traceparent</code> header:</p>



<pre class="wp-block-code"><code>traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             │  │                                │                │
             │  └─ trace ID                      └─ parent span   └─ flags
             └─ version</code></pre>



<p class="wp-block-paragraph">Break that chain anywhere and your trace splits in two, with the second half orphaned and useless. The usual culprits: a service that does not forward the header, a message queue where nobody propagated context into the consumer, or a thread pool that lost the context on handoff. Async boundaries are where this fails most often.</p>



<h3 class="wp-block-heading">Sampling, and why tail-based is worth the trouble</h3>



<p class="wp-block-paragraph">Tracing everything at scale is expensive, so you sample. The distinction matters:</p>



<ul class="wp-block-list">
<li><strong>Head-based</strong> — decide at the start of the request. Simple, cheap, and it throws away most of your errors, because errors are rare and the dice were rolled before anything went wrong.</li>

<li><strong>Tail-based</strong> — decide after the request finishes, when you know it was slow or failed. Keep every error, keep the slow tail, sample the boring successes at 1%.</li>
</ul>



<p class="wp-block-paragraph">Tail-based needs a collector that buffers spans until the trace completes, which costs memory and adds a component. It is almost always worth it, because a trace store full of successful requests is a trace store you never open.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Using all three: an incident, in order</h2>



<ol class="wp-block-list">
<li><strong>An alert fires on a metric.</strong> Checkout p99 latency crossed its threshold at 14:20. You know what and when.</li>

<li><strong>Check the metric&#8217;s shape.</strong> Step change or gradual climb? Correlated with a deploy, traffic spike, or nothing? Which service, which endpoint?</li>

<li><strong>Open a slow trace from that window.</strong> Not an average one — filter for the slow tail. Find the span holding the time.</li>

<li><strong>Take the trace ID into your logs.</strong> Now you are reading a handful of lines from the exact request, not grepping a firehose.</li>

<li><strong>Confirm the fix with the metric</strong> you started from. The graph coming back down is what closes the incident.</li>
</ol>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">Every step narrows the search. Metrics cover everything at low detail, traces cover one request at high detail, logs cover one moment at full detail. Going straight to logs means searching everything at full detail, which is why it feels like archaeology.</p>
</blockquote>



<h2 class="wp-block-heading">What to instrument if you are starting from nothing</h2>



<p class="wp-block-paragraph">Do not try to do all three at once. In order of value per hour spent:</p>



<ol class="wp-block-list">
<li><strong>Request rate, error rate and duration per endpoint.</strong> These three metrics answer most operational questions and take an afternoon with a middleware.</li>

<li><strong>Structured logs with a request ID.</strong> Even without tracing, a request ID threaded through your logs is transformative.</li>

<li><strong>Host metrics.</strong> CPU, memory, disk, network. Answers &#8220;is the machine underneath healthy&#8221; before you debug the application.</li>

<li><strong>Tracing on your slowest or most critical path.</strong> Not everything. Checkout, or whatever your equivalent is.</li>

<li><strong>Trace IDs in log lines,</strong> once tracing exists. This is the step that connects the tools.</li>
</ol>



<p class="wp-block-paragraph">Instrument with OpenTelemetry rather than a vendor SDK. The API is vendor-neutral, so switching backends later is a collector config change instead of touching every service. That is the single decision most likely to save you a painful migration.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Starting an investigation in logs rather than metrics.</li>

<li>Putting user IDs, request IDs or full URLs into metric labels.</li>

<li>Alerting on averages instead of percentiles.</li>

<li>Averaging percentiles across instances, which is not a meaningful operation.</li>

<li>Unstructured log messages that require regular expressions to query.</li>

<li>Head-based trace sampling, which discards most of your errors by construction.</li>

<li>Sampling error logs to save money.</li>

<li>No trace or request ID in logs, leaving the three tools unconnected.</li>

<li>Instrumenting with a vendor SDK and discovering the cost of that choice at renewal time.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Keep metric cardinality bounded and check your top series periodically.</li>

<li>Log structured JSON with consistent field names across services.</li>

<li>Propagate trace context everywhere, especially across queues and async boundaries.</li>

<li>Use tail-based sampling so errors and slow requests are always kept.</li>

<li>Set retention by usefulness: metrics for months, traces for days, logs somewhere between.</li>

<li>Alert on symptoms users feel — latency, errors, saturation — not on internal counters nobody can act on.</li>

<li>Standardise on OpenTelemetry so the backend stays replaceable.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Do I need all three?</h3>



<p class="wp-block-paragraph">Not immediately. A single service with good metrics and structured logs is well covered. Tracing earns its cost when a request crosses several services and &#8220;which one is slow&#8221; stops being obvious.</p>



<h3 class="wp-block-heading">What is the difference between monitoring and observability?</h3>



<p class="wp-block-paragraph">Monitoring watches for failure modes you predicted. Observability is being able to answer questions you did not think of in advance. Metrics and alerts are mostly monitoring; high-cardinality traces and structured logs are what make a system observable.</p>



<h3 class="wp-block-heading">Why is my Prometheus using so much memory?</h3>



<p class="wp-block-paragraph">Almost always cardinality. Memory tracks the number of active time series, not the number of hosts. Find the offending metric with a top-series query — it will usually be one where somebody added an unbounded label.</p>



<h3 class="wp-block-heading">Can I put logs into metrics or the reverse?</h3>



<p class="wp-block-paragraph">You can derive metrics from logs, and it is a reasonable stopgap when you cannot change the application. It is more expensive than emitting metrics directly and the resolution is worse. Going the other way — reconstructing event detail from metrics — is not possible, since aggregation discards it.</p>



<h3 class="wp-block-heading">How long should I keep each?</h3>



<p class="wp-block-paragraph">Metrics are cheap and useful for capacity planning, so months. Traces are large and mostly interesting while an incident is fresh, so days. Logs sit in between and are usually driven by compliance rather than debugging value.</p>



<h3 class="wp-block-heading">Is tracing worth it for a monolith?</h3>



<p class="wp-block-paragraph">Often yes, because spans still reveal internal structure — which query, which external call, how many times. The N+1 problem shows up just as clearly in a monolith as in a distributed system.</p>



<h2 class="wp-block-heading">Wrapping up</h2>



<p class="wp-block-paragraph">Metrics, logs and traces are not three ways of looking at the same data. Metrics are aggregates over time and answer whether something is wrong. Traces follow one request and answer where. Logs are individual events and answer why. Reach for them in that order and each step makes the next one smaller.</p>



<p class="wp-block-paragraph">If your setup is incomplete, the highest-value gap is usually the connective tissue rather than another data source. Trace IDs in structured logs, consistent field names, bounded metric labels. Those three make the tools you already have work together, which is worth more than adding a fourth.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Want your observability stack to actually answer questions?</h2>



<p class="wp-block-paragraph">I work with teams on observability that earns its cost — metrics with sane cardinality, structured logging that is queryable, OpenTelemetry instrumentation that survives a backend change, and alerts that fire on things people can act on.</p>



<p class="wp-block-paragraph">If your dashboards look healthy while users complain, you can hire me on Upwork.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/metrics-logs-traces/">Metrics, Logs and Traces: What Each One Answers</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Prometheus + Grafana From Scratch on One Server</title>
		<link>https://john-nessime.com/blog/devops/prometheus-grafana-one-server/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 30 Jul 2026 15:23:39 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Systemd]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=39</guid>

					<description><![CDATA[<p>Two static binaries, a package and four systemd units. How to install Prometheus and Grafana on a single server from scratch, wire in node_exporter, put nginx and TLS in front, and keep the ports that have no authentication off the public internet.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/prometheus-grafana-one-server/">Prometheus + Grafana From Scratch on One Server</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">You want to know when the disk fills up before a customer tells you. Search for how to do that and you get pointed at a hosted platform with per-host pricing, or a twelve-service Docker Compose file that somebody copy-pasted from a blog and does not fully understand either.</p>



<p class="wp-block-paragraph">For a single server, neither is the right answer. Prometheus and Grafana are two static Go binaries and a package. They run happily under systemd, use very little memory at this scale, and when something breaks you can read the logs and actually fix it. No container networking to debug, no orchestration layer between you and the problem.</p>



<p class="wp-block-paragraph">This walks through the whole thing: install Prometheus and Grafana from scratch, wire in node_exporter, put the lot behind nginx with TLS, and lock down the ports that should never have been public in the first place. Commands work on both Debian-family and RHEL-family systems, with the differences called out.</p>



<h2 class="wp-block-heading">What you are building</h2>



<p class="wp-block-paragraph">Four moving parts, all on one box:</p>



<ul class="wp-block-list">
<li><strong>node_exporter</strong> exposes machine metrics — CPU, memory, disk, network — on port 9100.</li>

<li><strong>Prometheus</strong> scrapes that endpoint on a schedule and stores the samples in its own time series database on port 9090.</li>

<li><strong>Grafana</strong> queries Prometheus and draws the dashboards on port 3000.</li>

<li><strong>nginx</strong> terminates TLS and is the only thing listening on a public interface.</li>
</ul>



<p class="wp-block-paragraph">The critical design decision is in that last line. Prometheus and node_exporter ship with no authentication at all. Bound to a public interface, they hand your entire infrastructure inventory to anyone who runs a port scan. Everything binds to 127.0.0.1 and nginx is the single front door.</p>



<p class="wp-block-paragraph">Resource-wise, a 2 GB VPS handles this comfortably for one server&#8217;s worth of metrics. Prometheus is the memory-hungry one, and its appetite scales with the number of active time series rather than the number of hosts.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Step 1: Users and directories</h2>



<p class="wp-block-paragraph">Neither service should run as root, and neither needs a login shell.</p>



<pre class="wp-block-code"><code>sudo useradd --system --no-create-home --shell /sbin/nologin prometheus
sudo useradd --system --no-create-home --shell /sbin/nologin node_exporter

sudo mkdir -p /etc/prometheus /var/lib/prometheus
sudo chown prometheus:prometheus /etc/prometheus /var/lib/prometheus</code></pre>



<p class="wp-block-paragraph">On Debian and Ubuntu the nologin shell lives at <code>/usr/sbin/nologin</code> rather than <code>/sbin/nologin</code>. Check with <code>which nologin</code> if useradd complains.</p>



<h2 class="wp-block-heading">Step 2: Install Prometheus</h2>



<p class="wp-block-paragraph">Prometheus ships as a single static binary. Rather than hardcoding a version that will be stale next month, pull the current release tag from the API:</p>



<pre class="wp-block-code"><code>PROM_VERSION=$(curl -s https://api.github.com/repos/prometheus/prometheus/releases/latest 
  | grep -Po '"tag_name": "vK[^"]*')
echo "Installing Prometheus ${PROM_VERSION}"

cd /tmp
curl -LO "https://github.com/prometheus/prometheus/releases/download/v${PROM_VERSION}/prometheus-${PROM_VERSION}.linux-amd64.tar.gz"
tar xzf "prometheus-${PROM_VERSION}.linux-amd64.tar.gz"
cd "prometheus-${PROM_VERSION}.linux-amd64"

sudo cp prometheus promtool /usr/local/bin/
sudo chown prometheus:prometheus /usr/local/bin/prometheus /usr/local/bin/promtool</code></pre>



<p class="wp-block-paragraph">If you are on ARM, swap <code>linux-amd64</code> for <code>linux-arm64</code>.</p>



<h3 class="wp-block-heading">The configuration file</h3>



<p class="wp-block-paragraph">Write <code>/etc/prometheus/prometheus.yml</code>:</p>



<pre class="wp-block-code"><code>global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: prometheus
    static_configs:
      - targets: ["127.0.0.1:9090"]

  - job_name: node
    static_configs:
      - targets: ["127.0.0.1:9100"]
        labels:
          instance: "web-01"</code></pre>



<p class="wp-block-paragraph">Set that <code>instance</code> label deliberately. It follows every metric into every dashboard and alert, and renaming it later means your historical graphs split in two.</p>



<p class="wp-block-paragraph">Validate before you start anything:</p>



<pre class="wp-block-code"><code>promtool check config /etc/prometheus/prometheus.yml</code></pre>



<h3 class="wp-block-heading">The systemd unit</h3>



<p class="wp-block-paragraph">Write <code>/etc/systemd/system/prometheus.service</code>:</p>



<pre class="wp-block-code"><code>[Unit]
Description=Prometheus
Wants=network-online.target
After=network-online.target

[Service]
User=prometheus
Group=prometheus
Type=simple
Restart=on-failure
RestartSec=5
ExecStart=/usr/local/bin/prometheus 
  --config.file=/etc/prometheus/prometheus.yml 
  --storage.tsdb.path=/var/lib/prometheus 
  --storage.tsdb.retention.time=30d 
  --storage.tsdb.retention.size=8GB 
  --web.listen-address=127.0.0.1:9090
ExecReload=/bin/kill -HUP $MAINPID

NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/prometheus
PrivateTmp=true

[Install]
WantedBy=multi-user.target</code></pre>



<p class="wp-block-paragraph">Two things worth noticing. <code>--web.listen-address=127.0.0.1:9090</code> is what keeps Prometheus off the public internet. And setting <em>both</em> retention flags matters — time-based retention alone will not save you if metric volume spikes, because Prometheus honours whichever limit it hits first.</p>



<pre class="wp-block-code"><code>sudo systemctl daemon-reload
sudo systemctl enable --now prometheus
sudo systemctl status prometheus</code></pre>



<h2 class="wp-block-heading">Step 3: node_exporter</h2>



<p class="wp-block-paragraph">Same pattern — a static binary and a unit file.</p>



<pre class="wp-block-code"><code>NE_VERSION=$(curl -s https://api.github.com/repos/prometheus/node_exporter/releases/latest 
  | grep -Po '"tag_name": "vK[^"]*')

cd /tmp
curl -LO "https://github.com/prometheus/node_exporter/releases/download/v${NE_VERSION}/node_exporter-${NE_VERSION}.linux-amd64.tar.gz"
tar xzf "node_exporter-${NE_VERSION}.linux-amd64.tar.gz"
sudo cp "node_exporter-${NE_VERSION}.linux-amd64/node_exporter" /usr/local/bin/
sudo chown node_exporter:node_exporter /usr/local/bin/node_exporter</code></pre>



<p class="wp-block-paragraph">Then <code>/etc/systemd/system/node_exporter.service</code>:</p>



<pre class="wp-block-code"><code>[Unit]
Description=Prometheus Node Exporter
Wants=network-online.target
After=network-online.target

[Service]
User=node_exporter
Group=node_exporter
Type=simple
Restart=on-failure
ExecStart=/usr/local/bin/node_exporter 
  --web.listen-address=127.0.0.1:9100

NoNewPrivileges=true
ProtectHome=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target</code></pre>



<pre class="wp-block-code"><code>sudo systemctl daemon-reload
sudo systemctl enable --now node_exporter

# Confirm it is actually producing metrics
curl -s http://127.0.0.1:9100/metrics | head -20</code></pre>



<p class="wp-block-paragraph">Now open <code>http://127.0.0.1:9090/targets</code> — via an SSH tunnel, since it is not public — and both jobs should read UP.</p>



<pre class="wp-block-code"><code># From your laptop
ssh -L 9090:127.0.0.1:9090 you@your-server</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Step 4: Install Grafana</h2>



<p class="wp-block-paragraph">Grafana is packaged properly, so use the official repository rather than a tarball. It gets you signed updates through your normal patching process.</p>



<p class="wp-block-paragraph"><strong>Debian and Ubuntu:</strong></p>



<pre class="wp-block-code"><code>sudo apt-get install -y apt-transport-https software-properties-common wget
sudo mkdir -p /etc/apt/keyrings/
wget -q -O - https://apt.grafana.com/gpg.key 
  | gpg --dearmor | sudo tee /etc/apt/keyrings/grafana.gpg &gt; /dev/null

echo "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com stable main" 
  | sudo tee /etc/apt/sources.list.d/grafana.list

sudo apt-get update
sudo apt-get install -y grafana</code></pre>



<p class="wp-block-paragraph"><strong>RHEL, AlmaLinux, Rocky:</strong></p>



<pre class="wp-block-code"><code>sudo tee /etc/yum.repos.d/grafana.repo &gt; /dev/null &lt;&lt;'EOF'
[grafana]
name=grafana
baseurl=https://rpm.grafana.com
repo_gpgcheck=1
enabled=1
gpgcheck=1
gpgkey=https://rpm.grafana.com/gpg.key
sslverify=1
sslcacert=/etc/pki/tls/certs/ca-bundle.crt
EOF

sudo dnf install -y grafana</code></pre>



<h3 class="wp-block-heading">Bind it to localhost too</h3>



<p class="wp-block-paragraph">Edit <code>/etc/grafana/grafana.ini</code>:</p>



<pre class="wp-block-code"><code>[server]
http_addr = 127.0.0.1
http_port = 3000
domain = metrics.example.com
root_url = https://metrics.example.com/

[security]
admin_user = admin
admin_password = put-something-real-here
cookie_secure = true

[users]
allow_sign_up = false</code></pre>



<p class="wp-block-paragraph">Getting <code>root_url</code> right matters more than it looks. Set it wrong and Grafana generates broken links and redirects behind the proxy, which produces a confusing half-working UI rather than a clean error.</p>



<pre class="wp-block-code"><code>sudo systemctl enable --now grafana-server</code></pre>



<h2 class="wp-block-heading">Step 5: Put nginx and TLS in front</h2>



<p class="wp-block-paragraph">This is the only piece that faces the internet.</p>



<pre class="wp-block-code"><code>server {
    listen 443 ssl;
    server_name metrics.example.com;

    ssl_certificate     /etc/letsencrypt/live/metrics.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/metrics.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Grafana Live needs websockets, and breaks silently without this
    location /api/live/ {
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_set_header Host       $host;
        proxy_pass http://127.0.0.1:3000;
    }
}</code></pre>



<p class="wp-block-paragraph">Note <code>fullchain.pem</code> rather than <code>cert.pem</code>. Getting that wrong produces a site that works in your browser and fails for anything else.</p>



<p class="wp-block-paragraph">Then close the metrics ports at the firewall, belt and braces:</p>



<pre class="wp-block-code"><code># firewalld
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload

# ufw
sudo ufw allow 443/tcp

# Verify nothing unexpected is listening publicly
sudo ss -tlnp | grep -E '9090|9100|3000'</code></pre>



<p class="wp-block-paragraph">Every line of that last command should show <code>127.0.0.1</code>. If you see <code>0.0.0.0</code> or <code>*</code>, something is exposed and you should fix it before going any further.</p>



<h2 class="wp-block-heading">Step 6: Connect Grafana to Prometheus</h2>



<p class="wp-block-paragraph">You can click through the UI, but provisioning it as a file means a rebuilt server comes back identical. Create <code>/etc/grafana/provisioning/datasources/prometheus.yml</code>:</p>



<pre class="wp-block-code"><code>apiVersion: 1

datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://127.0.0.1:9090
    isDefault: true</code></pre>



<p class="wp-block-paragraph">Restart Grafana, log in, and import dashboard ID <strong>1860</strong> from the Grafana dashboard library — Node Exporter Full. It is comprehensive, well maintained, and will show you more about your server than you knew was measurable.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Sizing the disk before it bites</h2>



<p class="wp-block-paragraph">Prometheus storage is predictable. Roughly:</p>



<pre class="wp-block-code"><code>disk = retention_seconds × samples_per_second × bytes_per_sample</code></pre>



<p class="wp-block-paragraph">Compressed samples land in the region of one to two bytes each. Measure your actual rate rather than guessing — run this in the Prometheus query box:</p>



<pre class="wp-block-code"><code>rate(prometheus_tsdb_head_samples_appended_total[5m])</code></pre>



<p class="wp-block-paragraph">One server with node_exporter at a 15 second interval is a small number, and 30 days fits in a few gigabytes. The number climbs fast once you add exporters, so keep <code>--storage.tsdb.retention.size</code> set as a hard ceiling. Prometheus filling the root partition takes the rest of the box down with it, which is a memorable way to discover your monitoring has no monitoring.</p>



<h2 class="wp-block-heading">Troubleshooting</h2>



<h3 class="wp-block-heading">A target shows DOWN</h3>



<p class="wp-block-paragraph">Work outward from the exporter:</p>



<pre class="wp-block-code"><code>curl -s http://127.0.0.1:9100/metrics | head
sudo systemctl status node_exporter
sudo journalctl -u prometheus -n 50 --no-pager</code></pre>



<p class="wp-block-paragraph">On RHEL-family systems with SELinux enforcing, this is frequently SELinux rather than your config. Check before you start changing things:</p>



<pre class="wp-block-code"><code>sudo ausearch -m avc -ts recent</code></pre>



<p class="wp-block-paragraph">If that shows denials, write a policy for them. Do not reach for <code>setenforce 0</code> — it fixes today&#8217;s symptom and quietly removes a layer of protection from the whole machine.</p>



<h3 class="wp-block-heading">Grafana loads but every panel says &#8220;No data&#8221;</h3>



<p class="wp-block-paragraph">Almost always the datasource URL. It must be <code>http://127.0.0.1:9090</code> from Grafana&#8217;s point of view, not your public hostname. Test the datasource from its settings page — Grafana reports the actual connection error, which is more useful than the empty panel.</p>



<h3 class="wp-block-heading">Prometheus will not start</h3>



<pre class="wp-block-code"><code>sudo journalctl -u prometheus -n 50 --no-pager
promtool check config /etc/prometheus/prometheus.yml
ls -la /var/lib/prometheus</code></pre>



<p class="wp-block-paragraph">The usual culprits are a YAML indentation error, or <code>/var/lib/prometheus</code> not being owned by the prometheus user after a manual file copy.</p>



<h3 class="wp-block-heading">Broken styling or redirect loops behind the proxy</h3>



<p class="wp-block-paragraph"><code>root_url</code> in grafana.ini does not match how you are actually reaching the site. If you are serving from a subpath, you also need <code>serve_from_sub_path = true</code>.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Leaving 9090, 9100 or 3000 on a public interface. Prometheus has no authentication whatsoever.</li>

<li>Keeping the default Grafana admin password because it prompts you to change it and you clicked past.</li>

<li>Setting no retention limits and discovering the problem when the root partition fills.</li>

<li>Dropping <code>scrape_interval</code> to 1s because more data seems better. It multiplies storage and buys you nothing at this scale.</li>

<li>Never backing up <code>/var/lib/grafana/grafana.db</code>, which holds every dashboard you have built.</li>

<li>Running everything as root because the tutorial you followed did.</li>

<li>Building dashboards but configuring no alerts, so you still find out from a user.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Bind every component to localhost and expose exactly one TLS endpoint.</li>

<li>Set both time and size retention. Whichever triggers first is the one you want.</li>

<li>Provision datasources and dashboards as files, so the config lives in version control.</li>

<li>Back up the Grafana SQLite database on the same schedule as everything else that matters.</li>

<li>Add an external uptime check. Self-hosted monitoring cannot tell you that the server it lives on has gone.</li>

<li>Write a handful of alerts you will genuinely act on. Alerts nobody responds to train people to ignore alerts.</li>

<li>Use systemd&#8217;s sandboxing directives. They cost nothing and limit what a compromised exporter can reach.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Should I use Docker instead?</h3>



<p class="wp-block-paragraph">For a single server, systemd is simpler. Two static binaries and a package, with no container networking layer between you and the logs. Containers earn their keep once you are managing several hosts or already run everything else that way.</p>



<h3 class="wp-block-heading">How much RAM does this need?</h3>



<p class="wp-block-paragraph">A 2 GB VPS is comfortable for one server. Prometheus memory tracks active time series rather than host count, so it grows when you add exporters, not when you add machines with the same exporters.</p>



<h3 class="wp-block-heading">Can I monitor more servers later?</h3>



<p class="wp-block-paragraph">Yes. Install node_exporter on each and add them as targets. The moment metrics cross a network, though, that traffic needs protecting — a VPN, an SSH tunnel, or TLS with client certificates. Do not simply open 9100 to the world.</p>



<h3 class="wp-block-heading">Where do alerts come from?</h3>



<p class="wp-block-paragraph">Two options. Grafana&#8217;s built-in alerting works from dashboard queries and is the quicker path. Alertmanager, run alongside Prometheus, is more powerful for routing, grouping and silencing. Start with Grafana&#8217;s and move if you outgrow it.</p>



<h3 class="wp-block-heading">How long should I keep metrics?</h3>



<p class="wp-block-paragraph">Thirty days covers most troubleshooting and capacity questions. Longer retention on a single box is usually a sign you want a remote-write backend rather than a bigger disk.</p>



<h3 class="wp-block-heading">Do I need node_exporter if I only care about my application?</h3>



<p class="wp-block-paragraph">Install it anyway. When the application misbehaves, the first question is whether the machine underneath it is healthy, and node_exporter is what answers that.</p>



<h2 class="wp-block-heading">Wrapping up</h2>



<p class="wp-block-paragraph">Once you install Prometheus and Grafana this way, the whole stack is four systemd units and three config files. Everything binds to localhost, nginx handles TLS, and there is no layer of abstraction between you and a problem when something breaks at an inconvenient hour.</p>



<p class="wp-block-paragraph">Get the dashboard working first, then spend an evening writing three or four alerts you will actually respond to. A dashboard tells you what happened when you go looking. An alert is what stops you needing to look.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Want this set up properly?</h2>



<p class="wp-block-paragraph">I build monitoring stacks for teams who would rather not spend a weekend on it — Prometheus and Grafana on bare servers or in Kubernetes, exporters for the things you actually run, alert rules tuned so they mean something, and dashboards that answer real questions rather than filling a screen.</p>



<p class="wp-block-paragraph">If that sounds useful, you can hire me on Upwork.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/prometheus-grafana-one-server/">Prometheus + Grafana From Scratch on One Server</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
