<?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>Grafana | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/grafana/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/grafana/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Sun, 25 Oct 2026 06:06:42 +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>Grafana | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/grafana/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>The Delay Was in the Data Three Weeks Ago: Building a Construction Early Warning Dashboard</title>
		<link>https://john-nessime.com/blog/data-engineering/construction-early-warning-dashboard/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 25 Oct 2026 06:06:42 +0000</pubDate>
				<category><![CDATA[Construction Technology]]></category>
		<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[Capital Projects]]></category>
		<category><![CDATA[Delay Analysis]]></category>
		<category><![CDATA[Earned Schedule]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Last Planner System]]></category>
		<category><![CDATA[Leading Indicators]]></category>
		<category><![CDATA[Owner Dashboard]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[Primavera P6]]></category>
		<category><![CDATA[Procore]]></category>
		<category><![CDATA[Project Controls]]></category>
		<category><![CDATA[Python]]></category>
		<category><![CDATA[Total Float]]></category>
		<category><![CDATA[XER Files]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=1033</guid>

					<description><![CDATA[<p>Schedule delay rarely arrives as a surprise. It sits in float erosion, aging RFIs and slipping weekly commitments long before the monthly update moves the finish date. Here is how to build a dashboard that catches it while there is still float left to spend.</p>
<p>The post <a href="https://john-nessime.com/blog/data-engineering/construction-early-warning-dashboard/">The Delay Was in the Data Three Weeks Ago: Building a Construction Early Warning Dashboard</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 monthly schedule update lands on a Thursday. The planner has re-run the logic, and substantial completion has moved three weeks to the right. The project manager asks the obvious question in the next meeting: why is this the first time anyone is hearing about it?</p>



<p class="wp-block-paragraph">The uncomfortable answer is that people did know. The superintendent knew the drywall crew had been short for two weeks. The project engineer knew a structural RFI had been sitting with the design team well past its due date. The foreman knew the weekly commitments kept slipping. None of it was hidden. It just never reached one screen, in one place, while there was still float left to spend.</p>



<p class="wp-block-paragraph">This post walks through building a <strong>construction early warning dashboard</strong> that catches those signals while they are still cheap to act on. It covers why schedule delay shows up late, the one data decision that makes early warning possible, the signals worth tracking, how to pull data out of Primavera P6, Microsoft Project and Procore, and how to alert without training everyone to ignore the alerts.</p>



<h2 class="wp-block-heading">Why Construction Delay Shows Up Three Weeks Late</h2>



<p class="wp-block-paragraph">Delay rarely arrives as one event. It accumulates in small amounts across activities that do not look critical yet. Three things keep it invisible.</p>



<ol class="wp-block-list">
<li><strong>The schedule is updated in batches.</strong> If the CPM schedule is statused monthly, or even biweekly, the finish date is only recalculated at that cadence. Everything that happened in between lands at once.</li>



<li><strong>Progress is self-reported.</strong> Percent complete is an estimate from whoever statuses the activity. Optimism is not dishonesty, but it compounds. An activity that sits at a comfortable-looking percentage for three updates is telling you something.</li>



<li><strong>The data is overwritten in place.</strong> This is the one that bites hardest. Most schedule tools, and most dashboards built on top of them, show the current value of float, the current forecast finish and the current status. Last month&#8217;s values are gone unless someone saved the file.</li>
</ol>



<p class="wp-block-paragraph">That third point is why so many project dashboards look fine right up until they don&#8217;t. A current-state dashboard tells you where you are. Delay warnings live in the rate of change. An activity with twelve days of float looks healthy. The same activity with twelve days of float, down from thirty over the last two updates, is the next critical path.</p>



<h2 class="wp-block-heading">The Design Decision That Makes Early Warning Possible: Snapshot Everything</h2>



<p class="wp-block-paragraph">Before any chart or alert, decide that every schedule update, every RFI log pull and every weekly plan gets stored as a dated snapshot. You append. You never overwrite. Storage for this is trivial; a few hundred activities per update is nothing for any database.</p>



<p class="wp-block-paragraph">PostgreSQL is what I reach for first here. It runs happily on a small managed instance like Amazon RDS or DigitalOcean Managed Databases, or on a modest VPS from Hetzner, Contabo or Interserver if you are comfortable handling backups yourself. A minimal activity snapshot table looks like this:</p>



<pre class="wp-block-code"><code>CREATE TABLE activity_snapshot (
    project_code     text        NOT NULL,
    data_date        date        NOT NULL,
    task_code        text        NOT NULL,
    task_name        text,
    total_float_days numeric,
    early_start      date,
    early_finish     date,
    is_complete      boolean     NOT NULL DEFAULT false,
    loaded_at        timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (project_code, data_date, task_code)
);</code></pre>



<p class="wp-block-paragraph">The primary key does the important work. Each activity appears once per data date, so loading the same export twice fails loudly instead of silently doubling the history. The <code>data_date</code> is the schedule&#8217;s status date, not the day you loaded the file, because trends must be plotted against when the schedule says it was true.</p>



<p class="wp-block-paragraph">Keying on <code>task_code</code>, the activity ID the planner sees, rather than an internal database ID matters too. Internal IDs can change when a schedule is re-imported. Activity IDs usually survive, and when they don&#8217;t, you want to know (more on that in troubleshooting).</p>



<h2 class="wp-block-heading">Early Warning Signals That Move Before the Finish Date Does</h2>



<p class="wp-block-paragraph">Each of these is a leading indicator of schedule delay. None is perfect alone. Together they give you weeks of notice instead of a surprise in the monthly update.</p>



<h3 class="wp-block-heading">Float Erosion on Near-Critical Paths</h3>



<p class="wp-block-paragraph">Watching the critical path is necessary but late. By the time an activity is critical, its float is already gone. The useful signal is near-critical work losing float quickly. This query compares each open activity to its own previous snapshot:</p>



<pre class="wp-block-code"><code>WITH history AS (
    SELECT
        project_code,
        task_code,
        task_name,
        data_date,
        total_float_days,
        LAG(total_float_days) OVER (
            PARTITION BY project_code, task_code
            ORDER BY data_date
        ) AS prev_float_days
    FROM activity_snapshot
    WHERE NOT is_complete
)
SELECT
    project_code,
    task_code,
    task_name,
    prev_float_days,
    total_float_days,
    prev_float_days - total_float_days AS float_lost_days
FROM history
WHERE data_date = (SELECT max(data_date) FROM activity_snapshot)
  AND prev_float_days IS NOT NULL
  AND total_float_days &lt;= 20
  AND prev_float_days - total_float_days &gt;= 5
ORDER BY float_lost_days DESC;</code></pre>



<p class="wp-block-paragraph">The thresholds (twenty days remaining, five days lost) are starting points, not rules. Tune them to your update cadence and project length. What matters is that the query looks at movement, which a current-state report cannot.</p>



<h3 class="wp-block-heading">Constraint Aging: RFIs and Submittals Against Need-By Dates</h3>



<p class="wp-block-paragraph">An overdue RFI on its own is noise. Every project has them. An overdue RFI that blocks an activity starting in ten days is a delay you can see coming.</p>



<p class="wp-block-paragraph">The hard part is the link. RFI and submittal logs in Procore or Autodesk Construction Cloud rarely point at schedule activity IDs. Someone has to maintain a small mapping table from RFI or submittal to activity, or at least to area and spec section. I would not skip it. Without the link you get a list of late paperwork; with it you get &#8220;this answer is needed in eight days and the ball has been in the architect&#8217;s court for three weeks.&#8221;</p>



<p class="wp-block-paragraph">The signal to chart is days of slack between the expected response and the need-by date of the linked activity. When it goes negative, the RFI is now on the schedule&#8217;s path whether the schedule knows it or not.</p>



<h3 class="wp-block-heading">Commitment Reliability: PPC and Reasons for Variance</h3>



<p class="wp-block-paragraph">If the field runs the Last Planner System, you already have a strong signal. Percent Plan Complete (PPC) is the share of weekly commitments finished as promised. Each week&#8217;s PPC is a lagging number, but the trend across weeks leads the CPM schedule, because missed promises show up in the field long before they show up as recalculated dates.</p>



<p class="wp-block-paragraph">Store reasons for variance as categories, not free text. A PPC dip caused by weather tells you something different from three straight weeks of &#8220;material not on site.&#8221; The second one is a procurement problem that will keep producing delay until someone fixes it.</p>



<h3 class="wp-block-heading">Look-Ahead Churn</h3>



<p class="wp-block-paragraph">Count how many times an activity&#8217;s planned start has moved across snapshots of the three- or six-week look-ahead. An activity pushed out once is normal planning. One that has been pushed four updates running is being deferred, not planned, and the reason is usually a constraint nobody has named yet.</p>



<h3 class="wp-block-heading">Earned Value, Read Carefully</h3>



<p class="wp-block-paragraph">If you track earned value, the Schedule Performance Index (SPI = EV / PV) belongs on the dashboard, with a caveat many dashboards skip. Classic SPI drifts back toward 1.0 as the project nears completion, even when the job is late, because earned value and planned value both end at the budget at completion. A late-stage SPI that looks like recovery may just be arithmetic.</p>



<p class="wp-block-paragraph">Earned Schedule fixes this by measuring in time rather than money. It asks at what point in the baseline your current earned value was supposed to be reached, then divides that by the actual time elapsed to get SPI(t). It needs no new data collection, only different math on the same numbers. If you only plot one schedule index late in a job, plot SPI(t).</p>



<h3 class="wp-block-heading">Claimed Progress Versus Manpower</h3>



<p class="wp-block-paragraph">Daily logs record who was on site. When an activity&#8217;s reported percent complete keeps rising while the manpower logged against that trade stays well below the plan, one of those numbers is wrong. Usually it is the percent complete. This comparison is crude, and it needs a consistent trade mapping, but it is one of the few checks that uses data nobody is trying to make look good.</p>



<h2 class="wp-block-heading">Getting the Data Out of P6, Microsoft Project and Procore</h2>



<p class="wp-block-paragraph">A construction project dashboard lives or dies on boring extraction work. Plan for it.</p>



<h3 class="wp-block-heading">Primavera P6 XER Exports</h3>



<p class="wp-block-paragraph">An XER file is plain tab-delimited text. Lines beginning with <code>%T</code> name a table, <code>%F</code> lists its fields, and <code>%R</code> lines are rows. Activities live in the <code>TASK</code> table. This reader pulls one table into a list of dictionaries:</p>



<pre class="wp-block-code"><code>def read_xer_table(path, table_name):
    rows, fields, current = [], None, None
    # XER exports often use a Windows code page, not UTF-8
    with open(path, encoding="cp1252", errors="replace") as fh:
        for line in fh:
            parts = line.rstrip("rn").split("t")
            tag = parts[0]
            if tag == "%T":
                current = parts[1]
            elif tag == "%F" and current == table_name:
                fields = parts[1:]
            elif tag == "%R" and current == table_name:
                rows.append(dict(zip(fields, parts[1:])))
    return rows

tasks = read_xer_table("update.xer", "TASK")
for t in tasks[:5]:
    print(t["task_code"], t["task_name"], t["total_float_hr_cnt"])</code></pre>



<p class="wp-block-paragraph">Two details catch people. Float and durations in XER are stored in hours (<code>total_float_hr_cnt</code>), so converting to days means dividing by the hours per day of each activity&#8217;s calendar, not a blanket eight. And an activity with a value in <code>act_end_date</code> has an actual finish, which is a clean way to set <code>is_complete</code> without relying on status labels.</p>



<p class="wp-block-paragraph">Use the float P6 stored in the export rather than recomputing the critical path yourself. P6&#8217;s result reflects its scheduling options, constraints and calendars. Your recompute probably won&#8217;t.</p>



<h3 class="wp-block-heading">Microsoft Project</h3>



<p class="wp-block-paragraph">Microsoft Project can save a schedule as XML, which carries tasks, dates and total slack. The same snapshot logic applies. Save one file per status date, load it with its data date, and never replace the previous one.</p>



<h3 class="wp-block-heading">Procore and Other Project Management Platforms</h3>



<p class="wp-block-paragraph">Procore&#8217;s REST API exposes RFIs and submittals per project, including status, due dates and ball-in-court. Two warnings. Most useful endpoints are scoped to a single project, so a portfolio view means iterating over the project list. And submittal status values can be configured per company, so map them explicitly rather than hard-coding what you saw on one account. Autodesk Construction Cloud has its own RFI API with a similar shape.</p>



<h3 class="wp-block-heading">The Spreadsheet Nobody Wants to Admit Exists</h3>



<p class="wp-block-paragraph">The weekly work plan is very often an Excel file or a Smartsheet. Accept that. A scheduled job that reads a fixed-layout sheet from a shared folder is less elegant than an API and far more likely to get used. Fighting the field&#8217;s tools is how early warning systems die.</p>



<h2 class="wp-block-heading">Building the Dashboard and Alerts People Don&#8217;t Mute</h2>



<p class="wp-block-paragraph">For visualization I lean toward Grafana, self-hosted or on Grafana Cloud, because its alert rules can run SQL directly against PostgreSQL and route results to email, Slack or Microsoft Teams. You can see the style of dashboard in this <a href="https://monitor.john-nessime.com/public-dashboards/4c1463601230401ab55159059364b739" target="_blank" rel="noreferrer noopener">live Grafana demo</a>.</p>



<p class="wp-block-paragraph">Power BI deserves a fair hearing. Many contractors already license it, project controls teams know it, and its reporting and drill-down are excellent for monthly reviews. Where it is weaker is rule-based alerting on arbitrary queries. If your organization lives in Microsoft 365, a sensible split is Power BI for the reporting pack and a small scheduled job or Grafana instance for the alerts. Metabase is another reasonable middle ground for smaller teams.</p>



<p class="wp-block-paragraph">Whatever the tool, the alert design matters more than the charts:</p>



<ul class="wp-block-list">
<li><strong>Send a weekly digest, not a page.</strong> Schedule risk is not an outage. A Monday summary of every signal that crossed a threshold gets read. Twenty individual notifications get filtered.</li>



<li><strong>Name the owner in the alert.</strong> &#8220;RFI 142 blocks level 3 slab pour, need-by in 8 days, ball in court: structural engineer&#8221; is actionable. &#8220;Constraint risk: high&#8221; is not.</li>



<li><strong>Compare the project to itself.</strong> Portfolio-wide thresholds punish complex jobs and let simple ones coast. A drop from its own recent average is the better trigger.</li>



<li><strong>Alert on stale data.</strong> If no schedule update has loaded in longer than your cadence, that is itself a warning. An early warning dashboard showing old data is worse than none.</li>
</ul>



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



<h3 class="wp-block-heading">Float values look wrong compared to P6</h3>



<p class="wp-block-paragraph">Check the hours-to-days conversion first. Then check which float definition the schedule uses. A project-level &#8220;must finish by&#8221; date or activity constraints can produce negative float that looks like a bug but is correct. Also confirm whether the planner defines critical as longest path or as float below a threshold.</p>



<h3 class="wp-block-heading">Trend lines break after a re-baseline or renumbering</h3>



<p class="wp-block-paragraph">If activity IDs change, <code>LAG()</code> finds no previous row and the activity appears brand new. Add a check that counts activities present in the previous snapshot but missing from the current one. A sudden jump means the history needs a mapping table, not a fresh start.</p>



<h3 class="wp-block-heading">PPC sits at or near 100 percent every week</h3>



<p class="wp-block-paragraph">That usually means commitments are being made at the end of the week, or tasks are so loose they cannot fail. A PPC that never moves carries no signal. Look at how commitments are recorded before trusting the number.</p>



<h3 class="wp-block-heading">The dashboard is green but the field says you are behind</h3>



<p class="wp-block-paragraph">Check how percent complete is calculated. P6 supports duration, physical and units percent complete types, and they can tell very different stories about the same activity. Believe the field, then find which input is out of step.</p>



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



<ul class="wp-block-list">
<li>Building on the live schedule only, with no history, so every trend has to be reconstructed by hand.</li>



<li>Watching only the critical path and missing the near-critical work that becomes critical next month.</li>



<li>Trusting classic SPI in the final third of a project, when it drifts toward 1.0 regardless of performance.</li>



<li>Tracking overdue RFIs without linking them to the activities they block.</li>



<li>Recomputing float yourself instead of using what the scheduling tool calculated.</li>



<li>Sending so many alerts that the project team creates an inbox rule to hide them.</li>
</ul>



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



<ul class="wp-block-list">
<li>Append every export with its data date. Never overwrite history.</li>



<li>Start with two signals, float erosion and constraint aging, and add more once those are trusted.</li>



<li>Agree with the planner on update cadence. The dashboard cannot be fresher than its inputs.</li>



<li>Keep the RFI-to-activity mapping small and owned by one named person.</li>



<li>Review thresholds after the first month. Early numbers are guesses.</li>



<li>Show each signal with its source and data date, so nobody argues about whether it is current.</li>
</ul>



<h2 class="wp-block-heading">Frequently Asked Questions</h2>



<h3 class="wp-block-heading">What are leading indicators of construction delay?</h3>



<p class="wp-block-paragraph">The most useful are float erosion on near-critical activities, RFIs and submittals aging toward the start of the work they block, a falling PPC trend, activities repeatedly pushed out of the look-ahead, and reported progress outpacing logged manpower.</p>



<h3 class="wp-block-heading">How often should the schedule be updated for early warning to work?</h3>



<p class="wp-block-paragraph">More often than monthly if you can manage it. Weekly or biweekly updates give the trend queries enough points to show movement. Signals from RFIs and weekly plans can refresh daily regardless of schedule cadence.</p>



<h3 class="wp-block-heading">Is SPI a good early warning metric?</h3>



<p class="wp-block-paragraph">Early and mid-project, yes, alongside float and critical path analysis. Late in a project, classic SPI drifts toward 1.0 even on late jobs, so the time-based SPI(t) from Earned Schedule is more trustworthy.</p>



<h3 class="wp-block-heading">Can I build this with Power BI instead of Grafana?</h3>



<p class="wp-block-paragraph">Yes for the reporting side, and it is often the better choice where the team already uses it. The snapshot database and trend logic are the same either way. Rule-based alerting is where you may want a separate tool.</p>



<h3 class="wp-block-heading">Do I need Primavera P6 for a construction early warning dashboard?</h3>



<p class="wp-block-paragraph">No. Any scheduling tool that exports activities with dates and float works, including Microsoft Project. What you need is a consistent export on every update and somewhere to keep the history.</p>



<h3 class="wp-block-heading">How do I link RFIs to schedule activities?</h3>



<p class="wp-block-paragraph">Usually with a small mapping table maintained by the project engineer: RFI or submittal number, activity ID, and need-by date. Mapping by area and spec section is a workable first step if activity-level links are too much at the start.</p>



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



<p class="wp-block-paragraph">The project manager who learns about delay three weeks late usually isn&#8217;t short of data. The problem is that the data is spread across a schedule, an RFI log and a weekly plan, and each one only remembers its latest state. A construction early warning dashboard works because it keeps the history and watches the movement: float draining from work that isn&#8217;t critical yet, answers arriving after the work that needs them, promises slipping week after week. Snapshot first. Everything else is a query on top.</p>



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



<h2 class="wp-block-heading">Need Help Building a Construction Early Warning Dashboard?</h2>



<p class="wp-block-paragraph">I build and host the data side of this for construction teams and project controls groups. Here is where I can help:</p>



<ul class="wp-block-list">
<li>Setting up a snapshot database that stores every P6 or Microsoft Project update with its data date.</li>



<li>Writing XER and XML loaders that handle hours-based float, calendars and renumbered activities.</li>



<li>Pulling RFIs and submittals from Procore or Autodesk Construction Cloud and linking them to activities.</li>



<li>Building Grafana dashboards for float erosion, constraint aging, PPC and SPI(t) trends.</li>



<li>Designing weekly digest alerts with named owners, sent to email, Slack or Teams.</li>



<li>Hosting and maintaining the dashboard so your team reads it instead of running it.</li>
</ul>



<p class="wp-block-paragraph">If you want to scope it first, the <a href="https://john-nessime.com/tools/engagement-builder/">Engagement Builder</a> lays out phases and effort. Or send me one recent XER export and your RFI log, and I will tell you which signals your data can already support.</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://john-nessime.com/projects/hosted-monitoring/">See Hosted Monitoring</a></div>



<div class="wp-block-button is-style-outline is-style-outline--1"><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/data-engineering/construction-early-warning-dashboard/">The Delay Was in the Data Three Weeks Ago: Building a Construction Early Warning Dashboard</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Monitoring Zoho Integrations: Catching the Failures That Never Throw an Error</title>
		<link>https://john-nessime.com/blog/devops/monitoring-zoho-integrations/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 06 Oct 2026 06:08:51 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Workflow Automation]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[Cardinality]]></category>
		<category><![CDATA[CloudWatch]]></category>
		<category><![CDATA[Data Integration]]></category>
		<category><![CDATA[Deadman Alerting]]></category>
		<category><![CDATA[Embedded Metric Format]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Grafana Alloy]]></category>
		<category><![CDATA[LogQL]]></category>
		<category><![CDATA[Loki]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Rate Limiting]]></category>
		<category><![CDATA[Webhooks]]></category>
		<category><![CDATA[Zoho]]></category>
		<category><![CDATA[Zoho CRM]]></category>
		<category><![CDATA[Zoho Flow]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=395</guid>

					<description><![CDATA[<p>Zoho integrations rarely fail loudly. Channels expire after an hour, credits drain against a rolling window, and bulk writes return HTTP 200 while rejecting records. Here is how to instrument for absence using CloudWatch Embedded Metric Format, Loki's absent_over_time, and Grafana across both.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/monitoring-zoho-integrations/">Monitoring Zoho Integrations: Catching the Failures That Never Throw an Error</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 came in on a Tuesday: &#8220;Have we stopped getting leads from the website form?&#8221; Not an alert. Not a page. A salesperson noticing the pipeline looked thin.</p>



<p class="wp-block-paragraph">The sync worker was healthy. Zero errors in the last twenty-four hours. CPU flat, memory flat, no restarts, no failed jobs. Every dashboard was green because every dashboard was measuring the wrong thing. The integration had stopped receiving anything from Zoho four days earlier, and an integration that receives nothing produces no errors at all.</p>



<p class="wp-block-paragraph">That is the whole problem with monitoring Zoho integrations. The failures that hurt do not throw exceptions. They show up as silence, as slowly degrading throughput, or as HTTP 200 responses that quietly did not do what you asked. This post covers how to instrument for those three failure families using CloudWatch, Grafana and Loki, and why the obvious metrics miss all three.</p>



<h2 class="wp-block-heading">The three ways Zoho integrations actually break</h2>



<p class="wp-block-paragraph">Before touching a dashboard, it helps to be honest about what you are defending against. In my experience these fall into three groups, and they need genuinely different instrumentation:</p>



<ol class="wp-block-list">
<li><strong>Silent stop.</strong> Zoho stops sending you events. Nothing fails, because nothing happens.</li>

<li><strong>Silent throttle.</strong> You run out of API credits or hit a concurrency ceiling. Throughput degrades before anything hard-fails.</li>

<li><strong>Silent drift.</strong> Calls succeed, records do not land, and the two systems diverge without a single error log.</li>
</ol>



<p class="wp-block-paragraph">An error-rate panel catches none of these. That is not a criticism of error-rate panels, it is just what they are for.</p>



<h2 class="wp-block-heading">Failure family one: the integration that just stops</h2>



<p class="wp-block-paragraph">If you push data out of Zoho CRM using the Notifications API, you subscribe a <code>notify_url</code> to module events and Zoho posts to it when records change. It works well. It also has a detail that catches people out repeatedly.</p>



<p class="wp-block-paragraph">Notification channels expire. Zoho&#8217;s documentation is explicit: the expiry can be set to a maximum of one week from the time the channel is enabled, and if you do not specify one, or you ask for longer than a week, the default expiry is <strong>one hour</strong>. So the failure mode is not &#8220;someone forgot to renew after a year.&#8221; It is &#8220;someone deployed a subscription script without an expiry field and the integration worked beautifully for sixty minutes.&#8221;</p>



<p class="wp-block-paragraph">What makes this genuinely hard rather than just annoying is that there is no endpoint to list your currently active channels. You cannot ask Zoho what it thinks it is subscribed to. You can only ask whether events are still arriving. That single fact should shape your entire monitoring design: you are not monitoring for errors, you are monitoring for <em>absence</em>.</p>



<h3 class="wp-block-heading">Emit a heartbeat, then alarm on its absence</h3>



<p class="wp-block-paragraph">The receiving end of the webhook should emit a metric on every delivery, including deliveries you decide to discard. Embedded Metric Format is the cheapest way to do this from Lambda or a container, because you write structured JSON to stdout and CloudWatch extracts the metrics asynchronously. No <code>PutMetricData</code> call, no synchronous latency in your request path.</p>



<pre class="wp-block-code"><code>{
  "_aws": {
    "Timestamp": 1710000000000,
    "CloudWatchMetrics": [
      {
        "Namespace": "ZohoIntegration",
        "Dimensions": [["Module", "Channel"]],
        "Metrics": [
          { "Name": "NotificationsReceived", "Unit": "Count" },
          { "Name": "ChannelSecondsRemaining", "Unit": "Seconds" }
        ]
      }
    ]
  },
  "Module": "Leads",
  "Channel": "leads-prod",
  "NotificationsReceived": 1,
  "ChannelSecondsRemaining": 486000,
  "record_id": "5725767000002166520",
  "operation": "insert"
}</code></pre>



<p class="wp-block-paragraph">Note what is a dimension and what is not. <code>Module</code> and <code>Channel</code> are bounded sets, so they are safe dimensions. <code>record_id</code> sits in the log body only. EMF creates a separate custom metric for every unique dimension combination, so promoting a record ID to a dimension is how you turn a monitoring project into a billing incident. AWS says this plainly in the EMF documentation and it is worth reading twice before you ship.</p>



<p class="wp-block-paragraph">Now the alarm. A normal CloudWatch alarm goes to <code>INSUFFICIENT_DATA</code> when the metric stops reporting, and <code>INSUFFICIENT_DATA</code> does not notify. That is precisely backwards for this failure. You want missing data treated as a breach:</p>



<pre class="wp-block-code"><code>aws cloudwatch put-metric-alarm 
  --alarm-name zoho-leads-notifications-silent 
  --namespace ZohoIntegration 
  --metric-name NotificationsReceived 
  --dimensions Name=Module,Value=Leads Name=Channel,Value=leads-prod 
  --statistic Sum 
  --period 3600 
  --evaluation-periods 2 
  --threshold 1 
  --comparison-operator LessThanThreshold 
  --treat-missing-data breaching 
  --alarm-actions arn:aws:sns:eu-west-1:111122223333:ops-alerts</code></pre>



<p class="wp-block-paragraph">The <code>--treat-missing-data breaching</code> flag is the entire point of that command. Without it, an integration that goes completely dead produces an alarm that sits quietly in an unknown state forever.</p>



<p class="wp-block-paragraph">On the Loki side the equivalent is <code>absent_over_time</code>, which returns 1 when a stream has produced no entries in the window. Run it from the Loki ruler so it fires even when nobody has a browser open:</p>



<pre class="wp-block-code"><code>groups:
  - name: zoho-integration
    interval: 5m
    rules:
      - alert: ZohoNotificationsSilent
        expr: absent_over_time({job="zoho-webhook", module="leads"}[2h])
        for: 15m
        labels:
          severity: critical
        annotations:
          summary: "No Zoho notifications for Leads in 2h"
          description: "Channel may have expired. Re-subscribe and check channel_expiry."</code></pre>



<p class="wp-block-paragraph">One caveat worth knowing: <code>absent_over_time</code> only works for label combinations you name explicitly. It cannot tell you that a stream you never thought about has gone quiet. That is a real limitation, and it means your channel inventory has to live in your own configuration, not be discovered from Loki.</p>



<p class="wp-block-paragraph">Because you cannot list channels through the API, treat renewal as a scheduled job with its own deadman alert, and emit <code>ChannelSecondsRemaining</code> at each renewal. Alarm when it drops below roughly a day. That gives you warning before the silence starts rather than after somebody notices.</p>



<h2 class="wp-block-heading">Failure family two: throttling that is not rate limiting</h2>



<p class="wp-block-paragraph">Most engineers instrument Zoho as if it were a requests-per-minute API. It is not, and that mismatch produces dashboards that look fine right up until calls start failing.</p>



<p class="wp-block-paragraph">Zoho CRM meters API usage in <strong>credits</strong>, not calls. Different operations deduct different amounts, and bulk operations are metered per batch of records rather than per request. The budget resets on a rolling twenty-four hour window measured from each call, not at midnight. If your dashboard has a daily reset at 00:00, it is drawing a line that does not exist.</p>



<h3 class="wp-block-heading">The gauge that appears halfway through the tank</h3>



<p class="wp-block-paragraph">Here is the detail that makes credit monitoring awkward. Zoho returns remaining credits in an <code>X-API-CREDITS-REMAINING</code> response header, but only once usage has reached fifty percent or more of the daily allowance. Below that threshold the header simply is not there.</p>



<p class="wp-block-paragraph">Read that again in monitoring terms. An absent header means either &#8220;you are comfortably under half your budget&#8221; or &#8220;your instrumentation is broken and you have no idea.&#8221; Those two states look identical, and one of them is fine while the other is a blind spot. So record both facts separately:</p>



<ul class="wp-block-list">
<li>A counter for responses where the header was <em>present</em>, which effectively tells you when you crossed the halfway mark.</li>

<li>A gauge of the value itself, which only has meaning while the header exists.</li>

<li>Your own client-side count of calls made, which works regardless and is the only thing you control.</li>
</ul>



<p class="wp-block-paragraph">The first crossing of that threshold is a useful early warning in its own right. Alert on the transition, not just on a low remaining value.</p>



<h3 class="wp-block-heading">Concurrency is a separate ceiling</h3>



<p class="wp-block-paragraph">Alongside credits, Zoho applies a concurrency limit: the number of calls active simultaneously per org. There is also a sub-concurrency limit covering the expensive operations such as COQL queries, composite requests, lead conversion, and inserts or updates above a small record count. Exceeding either returns <code>TOO_MANY_REQUESTS</code>.</p>



<p class="wp-block-paragraph">Two consequences matter for monitoring. First, since the limit is on simultaneous calls rather than calls per unit time, a requests-per-minute graph will never predict a concurrency failure. You need a gauge of in-flight requests, incremented before the call and decremented in a <code>finally</code> block. Second, the sub-concurrency ceiling is the same across every CRM edition, so upgrading the plan raises the general limit but does not buy headroom on the expensive calls. Batch those and queue them rather than fanning out.</p>



<p class="wp-block-paragraph">One more source of consumption that surprises people: integration tasks inside Deluge functions consume credits from the same org budget as your external API calls. If your credit graph climbs while your service is idle, someone&#8217;s workflow rule is spending your budget. Zoho&#8217;s API Dashboard breaks consumption down by application and function, which is where to look before blaming your own code.</p>



<h2 class="wp-block-heading">Failure family three: successful calls that did nothing</h2>



<p class="wp-block-paragraph">Bulk and multi-record operations return a per-record status inside the response body. The HTTP status can be perfectly healthy while individual records were rejected for validation rules, mandatory fields, duplicate checks or picklist values that do not exist in the target layout.</p>



<p class="wp-block-paragraph">If your client only checks the HTTP status, you will log a success, increment a success counter, and move on. Weeks later somebody asks why the CRM is missing a slice of records that your logs insist were written.</p>



<p class="wp-block-paragraph">The fix is to stop counting requests and start counting records, on both sides:</p>



<ul class="wp-block-list">
<li><strong>Records submitted</strong> versus <strong>records confirmed</strong> in the per-record response status.</li>

<li>A breakdown of rejection reasons as a low-cardinality label, so a spike in one specific validation error is visible.</li>

<li>A periodic reconciliation job comparing source counts against Zoho counts for a bounded window, which is the only thing that catches drift you failed to anticipate.</li>
</ul>



<p class="wp-block-paragraph">In LogQL, the divergence between the two counters is the panel that actually earns its place on the wall:</p>



<pre class="wp-block-code"><code>sum(count_over_time({job="zoho-sync"} | json | status = "submitted" [1h]))
  -
sum(count_over_time({job="zoho-sync"} | json | status = "confirmed" [1h]))</code></pre>



<p class="wp-block-paragraph">A healthy integration holds that near zero. A number that grows steadily is drift, and it is visible weeks before anyone in sales notices.</p>



<h2 class="wp-block-heading">Deciding which signal goes where</h2>



<p class="wp-block-paragraph">Running CloudWatch, Grafana and Loki together only helps if each one has a job. The split I reach for first:</p>



<ul class="wp-block-list">
<li><strong>CloudWatch</strong> holds the numbers you alarm on, especially where the workload already runs in AWS and EMF gives you metrics for free out of log output.</li>

<li><strong>Loki</strong> holds the full request and response context: which record, which module, which validation error, which channel. This is what you actually read at three in the morning.</li>

<li><strong>Grafana</strong> is the single pane over both, with CloudWatch and Loki added as separate data sources so one dashboard can show the credit gauge next to the rejection reasons.</li>
</ul>



<p class="wp-block-paragraph">The trap that spans both backends is cardinality, and it bites in the same way for different reasons. In CloudWatch, each unique dimension combination becomes a billable custom metric. In Loki, each unique label combination becomes a separate stream, and Loki was designed for a small number of long-lived streams. Record IDs, email addresses and timestamps belong in neither.</p>



<p class="wp-block-paragraph">Loki&#8217;s answer is structured metadata: high-cardinality fields attached to log lines without creating streams or entering the index. It needs enabling per tenant:</p>



<pre class="wp-block-code"><code>limits_config:
  allow_structured_metadata: true
  volume_enabled: true</code></pre>



<p class="wp-block-paragraph">Put <code>module</code>, <code>environment</code> and <code>job</code> in labels. Put the Zoho record ID and the correlation ID in structured metadata. Query with a label selector first and filter on the rest.</p>



<p class="wp-block-paragraph">On collection, one scheduling note that does age: Promtail reached end of life on 2 March 2026, with its collection capabilities folded into Grafana Alloy. If you are still shipping through Promtail, that migration is now maintenance debt rather than an optional upgrade, and Grafana published a conversion tool for existing configurations. New pipelines should start on Alloy.</p>



<p class="wp-block-paragraph">Where you run all this is a real decision. Self-hosting Loki on a VPS from a provider like Contabo or InterServer is cheap and entirely workable for a single integration&#8217;s log volume, and it keeps the data under your control. Grafana Cloud or Amazon Managed Grafana removes the operational surface if you would rather not babysit an ingester. Both are defensible. The honest trade-off is that self-hosted Loki is easy to stand up and less easy to keep healthy once retention and compaction start to matter.</p>



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



<ul class="wp-block-list">
<li><strong>EMF metrics never appear.</strong> The JSON must be a single line and structurally valid. A pretty-printed or truncated log line is ingested as an ordinary log event with no error, so check the raw event in the log stream rather than the metrics console.</li>

<li><strong>The alarm never fires despite obvious silence.</strong> Look at the alarm state. Sitting in <code>INSUFFICIENT_DATA</code> means the missing-data treatment was left at its default.</li>

<li><strong><code>absent_over_time</code> returns nothing.</strong> That is the success case. It emits a value only when the stream is empty, which makes it feel broken while everything is working.</li>

<li><strong>Loki queries slow to a crawl.</strong> Check stream count before blaming resources. A label added &#8220;temporarily&#8221; during debugging is the usual cause.</li>

<li><strong>Webhook deliveries stop after a deployment.</strong> Check whether the subscription script reran and created a channel without an expiry. An hour later it will look identical to a network problem.</li>

<li><strong>Credit consumption does not match your call count.</strong> Expected. Operations deduct different credit amounts, and Deluge integration tasks draw from the same pool.</li>
</ul>



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



<ul class="wp-block-list">
<li>Alerting on error rate alone, which is blind to every failure described above.</li>

<li>Creating notification channels without an explicit <code>channel_expiry</code> and inheriting the one-hour default.</li>

<li>Assuming an absent <code>X-API-CREDITS-REMAINING</code> header means healthy usage rather than sub-threshold usage.</li>

<li>Building a daily credit dashboard when the limit is a rolling window.</li>

<li>Putting record IDs into EMF dimensions or Loki labels.</li>

<li>Treating an HTTP 200 on a bulk write as confirmation that every record landed.</li>

<li>Running alert rules only in the Grafana UI, so nothing evaluates if that instance is down.</li>
</ul>



<h2 class="wp-block-heading">Best practices for monitoring Zoho integrations</h2>



<ol class="wp-block-list">
<li>Start with a deadman alert per channel. If you build one thing, build this.</li>

<li>Set <code>channel_expiry</code> explicitly, renew on a schedule, and monitor the renewal job as carefully as the integration.</li>

<li>Count records, not requests, on both sides of every write.</li>

<li>Keep a client-side call counter so you are never dependent on a header that may not be sent.</li>

<li>Gauge in-flight requests to see concurrency pressure before it becomes <code>TOO_MANY_REQUESTS</code>.</li>

<li>Attach a correlation ID to every record as structured metadata so one identifier joins logs and metrics.</li>

<li>Evaluate critical rules server-side, in the Loki ruler or as CloudWatch alarms.</li>

<li>Test by disabling a channel in a sandbox org and confirming the alert fires. An untested deadman alert is decoration.</li>
</ol>



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



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



<h3 class="wp-block-heading">Why did my Zoho webhook stop working on its own?</h3>



<p class="wp-block-paragraph">Almost always channel expiry. Zoho CRM notification channels last a maximum of one week, and if the expiry is omitted or set beyond that maximum, the channel defaults to one hour. Nothing is logged on your side because the deliveries simply stop arriving.</p>



<h3 class="wp-block-heading">Can I list my active Zoho notification channels?</h3>



<p class="wp-block-paragraph">There is no endpoint for enumerating active channels, which is a long-standing complaint in Zoho&#8217;s developer community. Keep the inventory in your own configuration and treat the receipt of events as the only reliable liveness signal.</p>



<h3 class="wp-block-heading">Do I need both CloudWatch and Loki?</h3>



<p class="wp-block-paragraph">No. Either can carry the whole job. The pairing makes sense when your integration already runs on AWS, because EMF gives you alarmable metrics from log output at no extra instrumentation cost, while Loki holds richer request context more cheaply. If you are starting fresh outside AWS, one backend is simpler and simpler is worth a lot.</p>



<h3 class="wp-block-heading">How do I monitor Zoho API credits properly?</h3>



<p class="wp-block-paragraph">Record the <code>X-API-CREDITS-REMAINING</code> header when it appears, alert on the moment it first appears in a window, and maintain your own call counter as the fallback. Model the budget as a rolling twenty-four hour window rather than a daily reset.</p>



<h3 class="wp-block-heading">What causes TOO_MANY_REQUESTS if I am under my credit limit?</h3>



<p class="wp-block-paragraph">Concurrency, not consumption. Credits and concurrency are independent ceilings. Expensive operations such as COQL queries and composite requests share a tighter sub-concurrency pool, so a burst of parallel queries can trip it while your credit balance is untouched.</p>



<h3 class="wp-block-heading">Should I still use Promtail for shipping integration logs?</h3>



<p class="wp-block-paragraph">No. Promtail reached end of life on 2 March 2026 and its functionality moved into Grafana Alloy. Existing deployments will keep running, but they are unsupported, and Grafana provides a tool to convert Promtail configuration to Alloy.</p>



<h3 class="wp-block-heading">How long should the deadman alert window be?</h3>



<p class="wp-block-paragraph">Base it on the quietest legitimate period for that module. A leads webhook that goes quiet overnight and at weekends needs a longer window than an order sync, or you will train the team to ignore it. Measure the real gap distribution for a fortnight before picking a number.</p>



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



<p class="wp-block-paragraph">Monitoring Zoho integrations is mostly the discipline of detecting absence. Expired channels, exhausted credits and silently rejected records all present as nothing happening, and nothing happening is invisible to the instrumentation most teams reach for first.</p>



<p class="wp-block-paragraph">So invert the default. Alarm when the count is too low rather than when the errors are too high. Set <code>--treat-missing-data breaching</code>. Use <code>absent_over_time</code>. Count records on both sides and watch the gap. If you build only one thing from this post, build the deadman alert on notification delivery, because that is the failure that will otherwise be reported to you by a salesperson wondering where the leads went.</p>



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



<h2 class="wp-block-heading">Need help instrumenting a Zoho integration?</h2>



<p class="wp-block-paragraph">Most of the work here is judgement about what to measure rather than volume of code. If you are staring at a sync that keeps going quiet, this is the sort of thing I take on:</p>



<ul class="wp-block-list">
<li>Building deadman alerting for Zoho notification channels, including renewal jobs that monitor themselves.</li>

<li>Instrumenting credit and concurrency headroom so throttling is visible before it becomes <code>TOO_MANY_REQUESTS</code>.</li>

<li>Designing EMF dimensions and Loki label schemas that will not turn into a cardinality bill.</li>

<li>Adding reconciliation jobs that compare record counts across systems and surface drift early.</li>

<li>Standing up Loki and Grafana Alloy, or migrating an existing Promtail pipeline that is now unsupported.</li>

<li>Reviewing an existing Grafana dashboard and cutting the panels that look reassuring but detect nothing.</li>
</ul>



<p class="wp-block-paragraph">Send me a sample webhook payload, a redacted response header set, or a screenshot of the dashboard you currently trust, and I will tell you what it would miss.</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-zoho-integrations/">Monitoring Zoho Integrations: Catching the Failures That Never Throw an Error</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>Grafana Monitoring for AWS Data Pipelines: The Green Dashboard Problem</title>
		<link>https://john-nessime.com/blog/devops/grafana-monitoring-aws-data-pipelines/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Fri, 21 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[Amazon Managed Grafana]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS Glue]]></category>
		<category><![CDATA[CloudWatch]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Data Freshness]]></category>
		<category><![CDATA[Data Observability]]></category>
		<category><![CDATA[ETL]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Metric Math]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[MWAA]]></category>
		<category><![CDATA[Notification Policies]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Pipeline Design]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Step Functions]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=260</guid>

					<description><![CDATA[<p>A Glue job that stops running emits no metrics, so the dashboard stays green and the alert quietly resolves itself. Here is why Grafana monitoring for AWS data pipelines misses that failure, and the heartbeat, metric math and no-data configuration that closes the gap, along with the CloudWatch query costs and IAM boundaries nobody warns you about.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/grafana-monitoring-aws-data-pipelines/">Grafana Monitoring for AWS Data Pipelines: The Green Dashboard Problem</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 arrives on a Monday. An analyst pings you: &#8220;the dashboard is showing last week&#8217;s numbers.&#8221; You open Grafana, look at the pipeline board, and every panel is calm. No red. No spikes. Error counts flat at zero.</p>



<p class="wp-block-paragraph">Then you check the Glue job history and the last successful run was six days ago. The job has not failed. It has not run at all. Because it never ran, it never emitted a metric, so CloudWatch recorded nothing and Grafana drew nothing. The panel is not green because the pipeline is healthy. It is green because the pipeline is silent.</p>



<p class="wp-block-paragraph">That gap is the most common problem I see with <strong>Grafana monitoring for AWS data pipelines</strong>, and it is not a Grafana bug. It follows from how AWS batch services emit metrics and how alert engines read an empty result set. This post covers that failure and the three that follow close behind: wrong signal, cost, and access. Then the dashboard layout, the alerting rules, and a troubleshooting list for empty panels.</p>



<h2 class="wp-block-heading">Failure family one: the pipeline stopped and nothing turned red</h2>



<p class="wp-block-paragraph">Application metrics are continuous. A web server emits request counts every minute forever, so a gap in the line genuinely means something is wrong.</p>



<p class="wp-block-paragraph">Pipeline metrics are not like that. Glue reports job metrics only while a job executes, and only if job metrics are enabled and the script initialises a GlueContext. Step Functions publishes execution metrics when executions happen. MWAA publishes DAG metrics when DAGs run. Between runs the metric does not exist. A daily job produces a few minutes of data and twenty-three hours of nothing.</p>



<p class="wp-block-paragraph">So the question that decides whether your monitoring works is: what does your alert engine do with an empty result?</p>



<h3 class="wp-block-heading">No Data and MissingSeries are not the same thing</h3>



<ul class="wp-block-list">
<li><strong>No Data</strong> means the query returned no data points at all. Grafana handles this with a per-rule setting and by default raises a separate alert named <code>DatasourceNoData</code>. That alert does not inherit your rule&#8217;s labels and it fires immediately, skipping the pending period. If your notification policy routes on a team label, it will not match.</li>



<li><strong>MissingSeries</strong> means the query still returns data but one series has disappeared. That is the dead-job case exactly: a query for failed tasks across all Glue jobs still returns rows for the jobs that ran, and the dead one just stops existing. Grafana holds the last state for a couple of evaluation cycles, then evicts the instance with a transition to Normal, annotated with <code>grafana_state_reason</code> set to <code>MissingSeries</code>.</li>
</ul>



<p class="wp-block-paragraph">Read that again, because it is the whole problem. A job that stops running produces an alert that resolves itself and sends a cheerful &#8220;back to normal&#8221; notification. Nobody investigates a resolution.</p>



<h3 class="wp-block-heading">The fix: stop inferring health from absence</h3>



<p class="wp-block-paragraph">Do not build the &#8220;did it run&#8221; signal out of metrics that only exist when it runs. Publish a heartbeat: a custom metric your pipeline writes once, deliberately, at the point you consider the run complete.</p>



<pre class="wp-block-code"><code>aws cloudwatch put-metric-data 
  --namespace "Pipelines" 
  --metric-name "RunCompleted" 
  --dimensions PipelineName=orders_daily 
  --value 1 
  --unit Count</code></pre>



<p class="wp-block-paragraph">Put that at the end of the Glue script, in the final Step Functions state, or in the last task of the DAG. Now &#8220;did it run&#8221; has a positive signal instead of an inferred one.</p>



<p class="wp-block-paragraph">The heartbeat is still sparse, so you also need to tell the evaluator that absence means failure. In a CloudWatch alarm, that is the missing-data setting: the options are <code>missing</code>, <code>ignore</code>, <code>breaching</code> and <code>notBreaching</code>, and for a heartbeat you want <code>breaching</code>.</p>



<pre class="wp-block-code"><code>aws cloudwatch put-metric-alarm 
  --alarm-name "orders_daily-heartbeat-missing" 
  --namespace "Pipelines" 
  --metric-name "RunCompleted" 
  --dimensions Name=PipelineName,Value=orders_daily 
  --statistic Sum 
  --period 3600 
  --evaluation-periods 26 
  --threshold 1 
  --comparison-operator LessThanThreshold 
  --treat-missing-data breaching 
  --alarm-actions arn:aws:sns:REGION:ACCOUNT_ID:pipeline-alerts</code></pre>



<p class="wp-block-paragraph">One subtlety here bites people. CloudWatch looks back over an evaluation range wider than your evaluation periods, and if it finds enough real data points in that wider range, the missing-data setting is ignored entirely. For a once-a-day metric that behaviour can suppress the alarm you just built. The reliable way around it is metric math: fill the gaps before the alarm evaluates anything.</p>



<pre class="wp-block-code"><code>FILL(m1, 0)</code></pre>



<p class="wp-block-paragraph">Now every period has a value, zero means &#8220;no run completed&#8221;, and a plain threshold behaves the way you expected. <code>FILL(m1, REPEAT)</code> and <code>FILL(m1, LINEAR)</code> carry the last known value or interpolate instead, which suits gauges better than heartbeats.</p>



<p class="wp-block-paragraph">On the Grafana side, set the no-data state explicitly on each rule and build a notification policy that catches <code>alertname=DatasourceNoData</code>, otherwise those alerts go nowhere. The heartbeat still matters even with that in place, because it turns a disappearing series into a present series with a bad value, and every rule engine handles that correctly.</p>



<h2 class="wp-block-heading">Failure family two: you are monitoring the compute, not the data</h2>



<p class="wp-block-paragraph">The second failure is subtler. The job runs, succeeds, takes its usual time, and writes nothing, because an upstream API returned an empty page or a partition filter matched no files. Executor CPU and heap will not tell you that. Two signals will.</p>



<p class="wp-block-paragraph"><strong>Freshness</strong> is time since the last successful completion, per pipeline. It maps directly onto &#8220;can I trust the report on my screen&#8221;, which is the only question your stakeholders are asking. Derive it from the heartbeat.</p>



<p class="wp-block-paragraph"><strong>Volume</strong> is rows or bytes written per run, compared against the same run last week. A drop to zero with a successful exit code is the classic empty-load bug. For Spark jobs on Glue the built-in counters give you a usable start:</p>



<ul class="wp-block-list">
<li><code>glue.driver.aggregate.recordsRead</code> and <code>glue.driver.aggregate.bytesRead</code> for input volume</li>



<li><code>glue.driver.aggregate.numFailedTasks</code> for partial failures that do not fail the job</li>



<li><code>glue.driver.s3.filesystem.write_bytes</code> and the <code>glue.ALL</code> equivalents for output across executors</li>
</ul>



<p class="wp-block-paragraph">Glue reports these roughly every thirty seconds as deltas from the previous report, so Sum over the run window is usually what you want rather than Average. Beyond those, the signals I put on a board first are <code>ExecutionsStarted</code> minus <code>ExecutionsSucceeded</code> from the <code>AWS/States</code> namespace, since aborted and timed-out executions land in separate counters and failure count alone misses them; DAG metrics from the <code>AmazonMWAA</code> namespace, remembering that <code>metrics.metrics_allow_list</code> and <code>metrics.metrics_block_list</code> control what reaches CloudWatch at all; consumer lag rather than throughput on streaming ingestion, because a stream will happily accept records nobody is reading; and message age on dead letter queues, which is a near-perfect proxy for &#8220;something is failing repeatedly and nobody noticed&#8221;.</p>



<h2 class="wp-block-heading">Failure family three: the dashboard costs more than the pipeline</h2>



<p class="wp-block-paragraph">Grafana&#8217;s CloudWatch data source uses <code>GetMetricData</code> to fetch samples and <code>ListMetrics</code> when you pick dimensions in the editor. <code>GetMetricData</code> does not qualify for the CloudWatch API free tier, and it is billed per metric requested rather than per API call. One request pulling five hundred metrics is five hundred billable metric requests.</p>



<p class="wp-block-paragraph">Now multiply: panels, times series per panel, times statistics per series, times refresh rate, times every user with the board open, times every alert rule on its own schedule. A wildcard query matching every Glue job in the account looks tidy in the editor and quietly becomes the largest line item in your monitoring bill. Asking for more than five statistics on one metric counts as an extra metric request too.</p>



<p class="wp-block-paragraph">The levers, roughly in order of return:</p>



<ol class="wp-block-list">
<li>Set a sane refresh interval. Metrics that arrive every thirty seconds do not need a five-second refresh, and a daily batch board does not need auto-refresh at all.</li>



<li>Replace wildcard dimension matching with explicit resource lists on always-open boards. Keep wildcards for ad hoc investigation.</li>



<li>Query only the regions you run in. It is easy to leave a data source pointed at a region you migrated out of.</li>



<li>Use template variables instead of one duplicated board per team.</li>



<li>If you pull a large metric catalogue continuously, evaluate CloudWatch metric streams. Streams push through Amazon Data Firehose to a destination you control, and AWS recommends that path for third-party tools with heavy <code>GetMetricData</code> usage. The trade-off is genuine: you lose metric math and Metrics Insights against CloudWatch directly, and you pick up storage and Firehose ingestion costs instead.</li>
</ol>



<p class="wp-block-paragraph">To find which client is driving the spend, enable CloudTrail data events on <code>GetMetricData</code>. It surfaces the calling IAM principal and source address, which is usually enough to identify the forgotten dashboard.</p>



<h2 class="wp-block-heading">Failure family four: access, accounts and regions</h2>



<p class="wp-block-paragraph">Grafana ships CloudWatch support natively, so there is no plugin to install. What there is, is an IAM policy people habitually make too broad or too narrow. This is the read-only set the data source needs:</p>



<pre class="wp-block-code"><code>{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowReadingMetricsFromCloudWatch",
      "Effect": "Allow",
      "Action": [
        "cloudwatch:DescribeAlarmsForMetric",
        "cloudwatch:DescribeAlarmHistory",
        "cloudwatch:DescribeAlarms",
        "cloudwatch:ListMetrics",
        "cloudwatch:GetMetricData",
        "cloudwatch:GetMetricStatistics"
      ],
      "Resource": "*"
    },
    {
      "Sid": "AllowReadingRegions",
      "Effect": "Allow",
      "Action": "ec2:DescribeRegions",
      "Resource": "*"
    },
    {
      "Sid": "AllowReadingResourcesForTags",
      "Effect": "Allow",
      "Action": "tag:GetResources",
      "Resource": "*"
    }
  ]
}</code></pre>



<p class="wp-block-paragraph">Prefer an assumed role over long-lived access keys. If Grafana runs on EC2 or ECS in the same account, the instance or task role is cleanest and there is no secret to rotate.</p>



<p class="wp-block-paragraph">Two boundaries to plan for. CloudWatch quotas are defined per account and per region, so a multi-account estate can hit limits in one account while the rest are fine, and each needs its own quota increase. And metrics do not cross regions: a pipeline that reads in one region and writes in another needs both on the board, and the missing half is a common reason a panel looks empty for no obvious cause.</p>



<h2 class="wp-block-heading">Where to run Grafana for this</h2>



<p class="wp-block-paragraph">Three options, and the choice is mostly about viewer count and who carries the operational load.</p>



<ul class="wp-block-list">
<li><strong>Amazon Managed Grafana</strong> bills per active user license per workspace, split into editor and viewer tiers, with at least one editor license required per workspace even if nobody logs in. An active user is anyone who logged in or made an API request during the billing cycle. Excellent for a small engineering team, expensive if you hand read-only boards to a large business audience.</li>



<li><strong>Grafana Cloud</strong> from Grafana Labs bills on usage rather than seats, which flips that calculation. It also brings the AWS observability integrations and Grafana Alloy if you want CloudWatch metrics in a Prometheus-compatible store.</li>



<li><strong>Self-hosted</strong> is cheapest per seat and most work. A small VPS from a provider like Contabo or InterServer runs Grafana for a handful of pipelines comfortably, and you own upgrades, TLS renewal and backups. One licensing note: Grafana&#8217;s open source core moved from Apache 2.0 to AGPLv3, while plugins and agents stayed Apache-licensed. If your legal team has a policy on AGPL, have that conversation before deployment.</li>
</ul>



<h2 class="wp-block-heading">Laying out a board people actually read</h2>



<p class="wp-block-paragraph">Organise by question, not by service. Whoever opens this board on a Monday morning has one question, and the answer should be visible without scrolling.</p>



<ol class="wp-block-list">
<li><strong>Freshness table.</strong> One row per pipeline, hours since last successful completion, threshold colour tied to that pipeline&#8217;s actual SLA. Most viewers read only this row.</li>



<li><strong>Volume against baseline.</strong> Records or bytes written per run, overlaid with the same period a week ago.</li>



<li><strong>Failure counts and durations</strong>, grouped by stage so a slow extract is distinguishable from a slow load.</li>



<li><strong>Resource detail at the bottom.</strong> Executor memory, DPU usage, throttling. Useful during an incident, noise the rest of the time.</li>
</ol>



<p class="wp-block-paragraph">Use a template variable for pipeline name rather than one board per pipeline. It keeps the query count down and new pipelines appear automatically. For cross-pipeline aggregation without fanning out into individual series, CloudWatch Metrics Insights runs a SQL-style query inside CloudWatch and the Grafana data source supports it:</p>



<pre class="wp-block-code"><code>SELECT SUM(ExecutionsFailed)
FROM SCHEMA("AWS/States", StateMachineArn)
GROUP BY StateMachineArn</code></pre>



<p class="wp-block-paragraph">Each <code>GetMetricData</code> operation can carry only one Metrics Insights query, so treat it as a way to consolidate one fan-out, not to run several at once.</p>



<h2 class="wp-block-heading">Troubleshooting: why the panel is empty</h2>



<ol class="wp-block-list">
<li><strong>Check the region on the query, not the data source.</strong> Grafana allows a per-query region override and it is easy to leave one on default.</li>



<li><strong>Widen the time range to seven days.</strong> If data appears, the metric is sparse and you have a heartbeat problem, not a query problem.</li>



<li><strong>Check the statistic.</strong> Sum and Average behave very differently on delta counters, and empty-looking panels are often Average over a window that is mostly gaps.</li>



<li><strong>Check dimensions match exactly.</strong> With Match Exact on, every dimension must be specified, so a metric published with two dimensions returns nothing when queried with one. Turning Match Exact off makes Grafana generate a search expression instead, which is the usual fix.</li>



<li><strong>Check job metrics are enabled.</strong> For Glue you need both the job metrics option and a GlueContext in the script. Without the GlueContext, nothing is emitted regardless of the console setting.</li>



<li><strong>Check IAM.</strong> A role with <code>ListMetrics</code> but not <code>GetMetricData</code> populates every dropdown perfectly and returns no data, which is confusing until you have seen it once.</li>



<li><strong>Check for throttling.</strong> Panels that are intermittently empty under load rather than consistently empty usually mean a CloudWatch API quota for that account and region.</li>
</ol>



<p class="wp-block-paragraph">For alert rules behaving oddly rather than panels, treat the Error state separately from No Data. Grafana raises <code>DatasourceError</code> on evaluation timeouts or repeated query failures, governed by the <code>evaluation_timeout</code> and <code>max_attempts</code> settings. Slow CloudWatch queries under quota pressure trip this, and the resulting alert looks nothing like the one you wrote.</p>



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



<ul class="wp-block-list">
<li>Alerting on failure counts only. A job that never starts cannot fail, so failure-count alerting is blind to the most common outage.</li>



<li>Leaving no-data handling on the default and never routing <code>DatasourceNoData</code>. The alert fires into the void.</li>



<li>Treating a MissingSeries resolution as good news. If alerts resolve without anyone acting, check the reason annotation.</li>



<li>Importing a large community dashboard pack wholesale. Most of those panels use wildcards, and you have just subscribed to a permanent <code>GetMetricData</code> bill for metrics nobody reads.</li>



<li>Monitoring the orchestrator but not the destination. Step Functions reports a clean success while the load that ran inside it wrote zero rows.</li>
</ul>



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



<ul class="wp-block-list">
<li>Emit a heartbeat from every scheduled pipeline using one consistent namespace and dimension scheme. Consistency is what lets a single dashboard cover everything.</li>



<li>Write down a freshness SLA per pipeline, then set the threshold from it. &#8220;Alert if stale&#8221; only means something once someone says how stale is too stale.</li>



<li>Provision dashboards and alert rules as code in the same repository as the pipeline. A pipeline that ships without its monitoring is not finished.</li>



<li>Route by severity, not by service. Freshness breaches page; executor memory warnings go to a channel.</li>



<li>Break something on purpose to test the alert. Disable the schedule on a non-production pipeline and confirm you get paged. An untested alert is a guess.</li>
</ul>



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



<h3 class="wp-block-heading">Do I need a plugin to connect Grafana to CloudWatch?</h3>



<p class="wp-block-paragraph">No. Grafana includes native support for the Amazon CloudWatch data source. Configure credentials or an assumed role, set a default region, and query. The same data source also reaches CloudWatch Logs and X-Ray.</p>



<h3 class="wp-block-heading">Why does my Grafana alert resolve itself when the pipeline is still broken?</h3>



<p class="wp-block-paragraph">Almost certainly MissingSeries eviction. When one series in a multi-series query disappears, Grafana holds the last state briefly and then transitions the instance to Normal with <code>grafana_state_reason</code> set to <code>MissingSeries</code>. It is not treated as No Data. A heartbeat metric with gaps filled to zero stops the series disappearing in the first place.</p>



<h3 class="wp-block-heading">CloudWatch dashboards or Grafana for AWS data pipelines?</h3>



<p class="wp-block-paragraph">CloudWatch dashboards are the shorter path if everything sits in one account and the visualisation options suit you. Grafana earns its place when you need pipeline metrics correlated with a warehouse query, a database, a trace or a business KPI on one screen, and when alerting has to route to several destinations. Most data platforms end up wanting the correlation.</p>



<h3 class="wp-block-heading">How do I monitor a Glue job that succeeds but writes no data?</h3>



<p class="wp-block-paragraph">Alert on output volume rather than exit status. Use the Glue write byte counters, or better, emit your own row-count metric after the write so it reflects what your code believes it wrote. Compare against the equivalent run a week earlier, because fixed thresholds age badly as data grows.</p>



<h3 class="wp-block-heading">Does Grafana querying CloudWatch cost money?</h3>



<p class="wp-block-paragraph">Yes. Grafana uses <code>GetMetricData</code>, which is billed per metric requested and excluded from the CloudWatch API free tier. Cost scales with panels multiplied by series multiplied by refresh frequency multiplied by viewers, so wildcards on an auto-refreshing board are the expensive pattern. Check current rates on the CloudWatch pricing page and do the arithmetic for your own board.</p>



<h3 class="wp-block-heading">Amazon Managed Grafana or self-hosted?</h3>



<p class="wp-block-paragraph">It comes down to viewer count. Managed Grafana bills per active user per workspace, which suits a small engineering team and gets costly when read-only dashboards go out widely. Self-hosting removes the per-seat cost and adds upgrade, TLS and backup work. Grafana Cloud sits between them on usage-based billing. Count likely viewers first, then choose.</p>



<h3 class="wp-block-heading">What is the minimum useful monitoring for a new pipeline?</h3>



<p class="wp-block-paragraph">A heartbeat proving the run completed, a row or byte count proving it moved something, and an alert that fires when the heartbeat is missing for longer than the agreed freshness window. Everything else is refinement.</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">Effective <strong>Grafana monitoring for AWS data pipelines</strong> is not about drawing more metrics. It is about refusing to infer health from absence. Batch pipelines are silent most of the time, and every monitoring system in existence reads silence as calm unless you tell it otherwise.</p>



<p class="wp-block-paragraph">Publish a heartbeat. Fill the gaps with zero. Set your no-data behaviour deliberately. Then alert on freshness and volume, because those are the two things people downstream will notice before you do. Do that, and a green panel means something.</p>



<h2 class="wp-block-heading">Need help building this on your AWS estate?</h2>



<p class="wp-block-paragraph">I design and build monitoring for AWS data platforms. This is the kind of work I take on:</p>



<ul class="wp-block-list">
<li>Heartbeat and freshness instrumentation across Glue, Step Functions, MWAA and Lambda pipelines, with one consistent namespace scheme so a single dashboard covers everything</li>



<li>Grafana dashboards built around freshness and volume rather than executor charts nobody reads</li>



<li>Alert rules and notification policies that handle No Data and MissingSeries correctly, routed to Slack, Teams, Telegram or email by severity</li>



<li>CloudWatch query cost reviews, tracing <code>GetMetricData</code> spend back to the dashboard or rule causing it</li>



<li>Cross-account and multi-region data source setup with least-privilege IAM roles instead of long-lived keys</li>



<li>Grafana deployment and hardening on Amazon Managed Grafana, Grafana Cloud or a self-hosted instance behind TLS</li>
</ul>



<p class="wp-block-paragraph">If you have a pipeline that failed quietly, send me the job definition, the alert rule, or a screenshot of the panel that stayed green. I would rather look at the actual thing than talk in generalities.</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-monitoring-aws-data-pipelines/">Grafana Monitoring for AWS Data Pipelines: The Green Dashboard Problem</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>Zero Errors, Zero Records: Monitoring Salesforce Integrations with CloudWatch and Grafana</title>
		<link>https://john-nessime.com/blog/devops/monitor-salesforce-integrations/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Mon, 03 Aug 2026 04:38:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS Lambda]]></category>
		<category><![CDATA[Cloud]]></category>
		<category><![CDATA[CloudWatch]]></category>
		<category><![CDATA[Data Integration]]></category>
		<category><![CDATA[ETL]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Logging]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Salesforce]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=97</guid>

					<description><![CDATA[<p>The error count was zero every day for three weeks. So was the invocation count. A stopped integration and a healthy one produce identical graphs, and every CloudWatch default is tuned to stay quiet when data stops arriving. Four signals worth emitting, and the alarm config that actually fires.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/monitor-salesforce-integrations/">Zero Errors, Zero Records: Monitoring Salesforce Integrations with CloudWatch and Grafana</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Sales ops asks why an account they created three weeks ago still isn&#8217;t in the warehouse. You open the dashboard for the sync. Error count: zero. Every day, flat, zero. The alarm is not firing.</p>



<p class="wp-block-paragraph">Then you check the invocation count and it is also zero, and has been since the day somebody disabled an EventBridge rule while cleaning up a different stack. The job has not run in three weeks. It never errored because it never started.</p>



<p class="wp-block-paragraph">This is the shape of almost every integration monitoring failure: <strong>a healthy graph and a stopped job produce identical output.</strong> Zero errors is what success looks like and it is also what absence looks like, and if the only thing you measure is failure, the two are indistinguishable.</p>



<p class="wp-block-paragraph">This is about how to <strong>monitor Salesforce integrations</strong> so that stopping is as loud as breaking. Four signals worth emitting, how to get them into CloudWatch cheaply, the alarm configuration that actually fires, and what belongs on a Grafana dashboard once you have them.</p>



<h2 class="wp-block-heading">Four signals, not one</h2>



<p class="wp-block-paragraph">Most integration monitoring stops at errors and duration, because those come free from Lambda or your container platform. Both are worth having and neither answers the question anybody actually asks, which is &#8220;is the data right&#8221;.</p>



<p class="wp-block-paragraph">The four that do:</p>



<ul class="wp-block-list">
<li><strong>Liveness.</strong> Did it run at all?</li>
<li><strong>Volume.</strong> Did it move a plausible amount of data?</li>
<li><strong>Freshness.</strong> How old is the newest record on the destination side?</li>
<li><strong>Budget.</strong> How much of Salesforce&#8217;s daily API allowance have you spent?</li>
</ul>



<p class="wp-block-paragraph">Errors are a fifth, and the least interesting, because errors are the failure mode that already announces itself.</p>



<h2 class="wp-block-heading">Liveness: the alarm that has to fire on silence</h2>



<p class="wp-block-paragraph">Emit a metric on every successful completion. A single count, value 1. Then alarm when it stops arriving.</p>



<p class="wp-block-paragraph">The trap is in the CloudWatch defaults. <code>TreatMissingData</code> has four settings, and the default is <code>missing</code>, which sends the alarm to <code>INSUFFICIENT_DATA</code> when nothing arrives. That state is not <code>ALARM</code>. Nothing pages. Your dashboard shows a grey alarm that most people read as &#8220;fine&#8221;.</p>



<p class="wp-block-paragraph">So heartbeat alarms need <code>breaching</code>. That much is standard advice. Here is the part that is not: <strong>even with <code>breaching</code> set, a heartbeat alarm can still fail to fire.</strong> CloudWatch evaluates over a range wider than your evaluation periods, and if it finds any real data point in that wider range, those override the missing ones. On a job that runs hourly, a successful run from earlier can keep the alarm quiet through several missed runs.</p>



<p class="wp-block-paragraph">The robust version uses metric math to turn absence into a real zero, so there is no missing data to interpret:</p>



<pre class="wp-block-code"><code>aws cloudwatch put-metric-alarm 
  --alarm-name "opportunity-sync-not-running" 
  --alarm-description "No completed run in the last 90 minutes" 
  --comparison-operator LessThanThreshold 
  --threshold 1 
  --evaluation-periods 1 
  --treat-missing-data breaching 
  --alarm-actions "$SNS_TOPIC_ARN" 
  --metrics '[
    {
      "Id": "runs",
      "MetricStat": {
        "Metric": {
          "Namespace": "SalesforceSync",
          "MetricName": "RunCompleted",
          "Dimensions": [{"Name": "Integration", "Value": "opportunity-sync"}]
        },
        "Period": 5400,
        "Stat": "Sum"
      },
      "ReturnData": false
    },
    {
      "Id": "filled",
      "Expression": "FILL(runs, 0)",
      "ReturnData": true
    }
  ]'</code></pre>



<p class="wp-block-paragraph"><code>FILL(runs, 0)</code> substitutes a zero wherever the metric has no data point, so the alarm always has something real to compare against the threshold. The window is deliberately longer than the schedule: an hourly job gets ninety minutes, so one late run does not wake anybody.</p>



<p class="wp-block-paragraph">Then do the thing everyone skips: disable the schedule in a test account and confirm the alarm actually goes red. An untested alarm is a belief, not a control.</p>



<h2 class="wp-block-heading">Volume and freshness: is the data actually moving</h2>



<p class="wp-block-paragraph">A job can complete successfully and process nothing. A credential with the wrong field-level permissions, a filter that silently matches nothing, a watermark that got written before the data landed: all of these produce a clean run and an empty result.</p>



<p class="wp-block-paragraph"><strong>Volume</strong> is the count of records read and written per run. Alarming on it is harder than liveness because the right number varies: a quiet Sunday legitimately looks like a broken Tuesday. Two approaches that work. Use CloudWatch anomaly detection, which learns the daily and weekly shape and alarms on departures from it. Or set a crude floor that only catches the catastrophic case, which is usually zero, and accept that you will not catch a fifty percent drop.</p>



<p class="wp-block-paragraph">I would start with the crude floor. It is five minutes of work and catches the failure that actually happens.</p>



<p class="wp-block-paragraph"><strong>Freshness</strong> is the better metric and almost nobody emits it. At the end of each run, query the destination for the newest record&#8217;s modified timestamp, subtract it from now, and publish the difference in seconds. That single number answers the business question directly: how far behind Salesforce are we right now?</p>



<p class="wp-block-paragraph">It also collapses several failure modes into one signal. A stopped job, a job that runs but writes nothing, a job stuck retrying, a job silently filtered down to zero rows: all of them show up as lag climbing. If you only add one metric from this post, add this one.</p>



<h2 class="wp-block-heading">Budget: watch the Salesforce allowance</h2>



<p class="wp-block-paragraph">Your org has a daily API allowance shared across every integration touching it. Exceeding it does not just break your sync; it breaks marketing automation, support tooling, and whatever else somebody connected two years ago.</p>



<p class="wp-block-paragraph">Enforcement is initially soft, and then it is not: past a protection threshold, calls come back as 403 with <code>REQUEST_LIMIT_EXCEEDED</code> until the rolling window drains. By that point you are in an incident that spans several teams.</p>



<p class="wp-block-paragraph">The good news is that this costs nothing to observe. Salesforce returns your current consumption on ordinary REST responses in a header, so you get it on calls you were making anyway:</p>



<pre class="wp-block-code"><code># Sforce-Limit-Info: api-usage=1212/15000
#
# Free: no extra API call, which matters when the thing you are
# measuring is an API budget. Add real error handling before
# shipping this; the header is not guaranteed on every response.
raw = response.headers.get("Sforce-Limit-Info", "")
used, allowed = (int(v) for v in raw.split("api-usage=")[1].split("/"))
emit("ApiUsagePercent", used / allowed * 100)</code></pre>



<p class="wp-block-paragraph">For a fuller picture, the <code>/services/data/vXX.X/limits</code> endpoint returns every allocation in the org, including <code>DailyApiRequests</code> with its max and remaining values. It needs the View Setup and Configuration permission and the numbers lag by a few minutes. Poll it on a schedule rather than per request, and alarm on percentage consumed rather than absolute calls, so the alarm survives a licence change.</p>



<p class="wp-block-paragraph">Graph consumption by integration if you can attribute it. The conversation about which team is burning the allowance goes very differently when there is a chart.</p>



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



<h2 class="wp-block-heading">Getting the metrics in without a bill shock</h2>



<p class="wp-block-paragraph">You can call <code>PutMetricData</code> directly, and it works, and it is a synchronous API call in the hot path of your job that can fail or add latency. Custom metrics are also charged per metric per month, and a metric is every unique combination of name and dimensions, so a dimension with high cardinality gets expensive quietly.</p>



<p class="wp-block-paragraph">The better default is Embedded Metric Format: write structured JSON to stdout and CloudWatch extracts the metrics from your logs. No API call, no added latency, and the log line stays queryable in Logs Insights alongside the metric.</p>



<pre class="wp-block-code"><code>{
  "_aws": {
    "Timestamp": 1700000000000,
    "CloudWatchMetrics": [{
      "Namespace": "SalesforceSync",
      "Dimensions": [["Integration"]],
      "Metrics": [
        { "Name": "RunCompleted",     "Unit": "Count"   },
        { "Name": "RecordsWritten",   "Unit": "Count"   },
        { "Name": "SourceLagSeconds", "Unit": "Seconds" },
        { "Name": "ApiUsagePercent",  "Unit": "Percent" }
      ]
    }]
  },
  "Integration": "opportunity-sync",
  "RunId": "a41c9f",
  "RunCompleted": 1,
  "RecordsWritten": 4127,
  "SourceLagSeconds": 312,
  "ApiUsagePercent": 8.1
}</code></pre>



<p class="wp-block-paragraph">Note what is a dimension and what is not. <code>Integration</code> is a dimension because it has a handful of values and you want to alarm per integration. <code>RunId</code> is a plain field: searchable in the logs, and not a dimension, because making it one would create a new metric on every run. That distinction is the whole cost story.</p>



<h2 class="wp-block-heading">Grafana on top</h2>



<p class="wp-block-paragraph">CloudWatch dashboards are fine and Grafana is better for this, for three reasons: you can put Salesforce metrics next to your warehouse and application metrics on one screen, the alerting is more expressive, and non-engineers will actually open it.</p>



<p class="wp-block-paragraph">Add CloudWatch as a data source using an IAM role rather than access keys, scoped to <code>cloudwatch:GetMetricData</code>, <code>cloudwatch:ListMetrics</code> and the Logs Insights permissions if you want log panels. One honest cost note: Grafana queries CloudWatch through the metric data API, which is billed per metric requested, so a busy dashboard on a short refresh interval is a real line item. Set a sane refresh, avoid auto-refresh on wall displays, and use the caching in Grafana&#8217;s CloudWatch data source.</p>



<p class="wp-block-paragraph">What goes on the dashboard, in order down the page:</p>



<ul class="wp-block-list">
<li><strong>Freshness per integration</strong>, as a stat panel with thresholds. This is the panel people look at.</li>
<li><strong>Time since last successful run</strong>, per integration.</li>
<li><strong>Records processed</strong>, over a window long enough to show the weekly shape.</li>
<li><strong>API allowance consumed</strong>, as a percentage with a threshold line.</li>
<li><strong>Errors and duration</strong>, at the bottom, where they belong.</li>
</ul>



<p class="wp-block-paragraph">One dashboard, one screen, no scrolling. A dashboard nobody can read at a glance during an incident is decoration.</p>



<h2 class="wp-block-heading">Alerts people don&#8217;t ignore</h2>



<p class="wp-block-paragraph">Decide deliberately where alerting lives. CloudWatch alarms are more reliable, because they keep working when Grafana is down, and Grafana alerts are more flexible and can span data sources. My default is CloudWatch for the small number of alerts that page someone, and Grafana for everything informational.</p>



<p class="wp-block-paragraph">Three things that separate a useful alert from noise. Alarm on the symptom, not the cause: &#8220;Opportunity data is more than two hours stale&#8221; is actionable in a way &#8220;Lambda errors greater than zero&#8221; is not. Put the runbook link in the alarm description, since that field ends up in the notification and is the only documentation anybody reads at midnight. And use composite alarms to suppress the cascade, so a Salesforce outage produces one page rather than nine.</p>



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



<ul class="wp-block-list">
<li>Monitoring only errors, so a stopped job looks identical to a healthy one.</li>
<li>Leaving <code>TreatMissingData</code> at its default on a heartbeat alarm.</li>
<li>Setting it to <code>breaching</code> and assuming that is sufficient, without handling the evaluation range.</li>
<li>Never testing that an alarm fires by actually breaking something.</li>
<li>No freshness metric, so nobody can answer how far behind the data is.</li>
<li>Ignoring API allowance until an integration you do not own breaks.</li>
<li>High-cardinality dimensions such as record ID or run ID, and the bill that follows.</li>
<li>Calling <code>PutMetricData</code> synchronously in the job&#8217;s critical path.</li>
<li>Alerting on causes rather than on user-visible symptoms.</li>
<li>A dashboard that requires scrolling and interpretation during an incident.</li>
</ul>



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



<ul class="wp-block-list">
<li>Emit liveness, volume, freshness and API budget from every integration, as a standard.</li>
<li>Heartbeat alarms with <code>breaching</code> plus <code>FILL()</code>, and a window longer than the schedule.</li>
<li>Freshness as the headline metric, because it maps to a question the business asks.</li>
<li>Embedded Metric Format rather than direct API calls.</li>
<li>Low-cardinality dimensions; everything else stays a log field.</li>
<li>Alarm on percentage of the API allowance, not absolute calls.</li>
<li>IAM roles for the Grafana data source, and a refresh interval you have costed.</li>
<li>Runbook links in alarm descriptions.</li>
<li>Composite alarms to collapse cascades into one page.</li>
<li>A quarterly test that breaks each integration on purpose and confirms someone gets told.</li>
</ul>



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



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



<h3 class="wp-block-heading">Why didn&#8217;t my CloudWatch alarm fire when the job stopped?</h3>



<p class="wp-block-paragraph">Almost certainly <code>TreatMissingData</code>. The default sends the alarm to <code>INSUFFICIENT_DATA</code>, which is not <code>ALARM</code> and pages nobody. Set it to <code>breaching</code>, and wrap the metric in <code>FILL()</code> so there is no missing data for CloudWatch to reinterpret.</p>



<h3 class="wp-block-heading">What&#8217;s the single most useful metric to add?</h3>



<p class="wp-block-paragraph">Freshness: how old the newest record on the destination side is. It catches stopped jobs, empty runs, stuck retries and silent filtering with one number, and it is the only one of these metrics a non-engineer can interpret.</p>



<h3 class="wp-block-heading">CloudWatch dashboards or Grafana?</h3>



<p class="wp-block-paragraph">Grafana if you already run it, because you can put Salesforce, warehouse and application metrics on one screen. CloudWatch if you do not, because a second system to operate is not free. Either way keep the paging alarms in CloudWatch so they survive Grafana being down.</p>



<h3 class="wp-block-heading">Will custom metrics be expensive?</h3>



<p class="wp-block-paragraph">Only if you make them so. Cost scales with unique name-and-dimension combinations, so a handful of metrics dimensioned by integration name is negligible. Adding a run ID or record ID as a dimension is how the bill grows without anyone noticing.</p>



<h3 class="wp-block-heading">How do I monitor a third-party connector I can&#8217;t add code to?</h3>



<p class="wp-block-paragraph">Monitor the destination instead. A scheduled job that queries the target for the newest record&#8217;s timestamp and emits it as a freshness metric works regardless of what wrote the data, and it is arguably a better test because it measures the outcome rather than the process.</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">Absence of failure is not evidence of success. An integration that stopped produces exactly the same error graph as one working perfectly, and every default in your monitoring stack is tuned to stay quiet when data stops arriving rather than to shout about it.</p>



<p class="wp-block-paragraph">So measure the thing you actually care about. Not &#8220;did it error&#8221; but &#8220;how stale is the data right now&#8221;, alarmed in a way that fires on silence, and tested by deliberately breaking it. Everything else on the dashboard is supporting evidence.</p>



<h2 class="wp-block-heading">Want this built properly?</h2>



<p class="wp-block-paragraph">Integration monitoring tends to get added after the first silent failure, which is one failure too late. Work I take on:</p>



<ul class="wp-block-list">
<li>Instrumenting Salesforce integrations with liveness, volume, freshness and API budget metrics via CloudWatch.</li>
<li>Auditing existing alarms for the ones that cannot fire, and fixing the missing-data handling.</li>
<li>Building the Grafana dashboard and data source, including cost-aware query and refresh configuration.</li>
<li>Alert design: symptom-based alarms, composite alarms to suppress cascades, runbooks attached where people will read them.</li>
<li>API allowance monitoring and attribution across multiple integrations sharing one org.</li>
<li>Running a failure drill so you know the alerting works before you need it.</li>
</ul>



<p class="wp-block-paragraph">Tell me how you would currently find out that a sync stopped, and I will tell you how long it would take.</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/monitor-salesforce-integrations/">Zero Errors, Zero Records: Monitoring Salesforce Integrations with CloudWatch and Grafana</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
