<?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>Monitoring | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/monitoring/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/monitoring/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Mon, 14 Sep 2026 14:34:19 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>Monitoring | John Nessime</title>
	<link>https://john-nessime.com/blog/monitoring/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<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>Instrument Once, Export Anywhere: OpenTelemetry on AWS With ADOT</title>
		<link>https://john-nessime.com/blog/devops/opentelemetry-on-aws-adot/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 01 Sep 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[ADOT]]></category>
		<category><![CDATA[Amazon ECS]]></category>
		<category><![CDATA[Amazon EKS]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cardinality]]></category>
		<category><![CDATA[CloudWatch]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[OpenTelemetry]]></category>
		<category><![CDATA[OTLP]]></category>
		<category><![CDATA[Semantic Conventions]]></category>
		<category><![CDATA[SigV4]]></category>
		<category><![CDATA[Tail Sampling]]></category>
		<category><![CDATA[Tracing]]></category>
		<category><![CDATA[Transaction Search]]></category>
		<category><![CDATA[Vendor Lock-In]]></category>
		<category><![CDATA[X-Ray]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=523</guid>

					<description><![CDATA[<p>A pipeline that returns 200 is not a pipeline that works. The four decisions behind running OpenTelemetry on AWS with ADOT, the CloudWatch OTLP endpoint limits that drop data without erroring, and how to catch the failures before a colleague does.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/opentelemetry-on-aws-adot/">Instrument Once, Export Anywhere: OpenTelemetry on AWS With ADOT</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">A colleague messages you: &#8220;checkout fell off the service map.&#8221; The service is fine. It&#8217;s serving traffic, the collector pod is <code>Running</code>, the exporter queue is empty, and nothing in the collector log reads like an error. But a chunk of the spans that left the application never showed up in CloudWatch.</p>



<p class="wp-block-paragraph">That&#8217;s the signature failure of telemetry pipelines on AWS. Transport worked, authentication worked, the endpoint answered <code>200</code>. It kept part of the payload and dropped the rest, because something in the batch broke a limit your collector has no idea exists.</p>



<p class="wp-block-paragraph">Getting started with OpenTelemetry on AWS is not the hard part. AWS Distro for OpenTelemetry (ADOT) will have you shipping traces in an afternoon. The hard part is four decisions underneath it: where the collector sits, which door telemetry uses to get into AWS, what happens to your metrics on the way through, and whether a second backend later costs you a config line or a migration. This post covers those four decisions and how each one fails without announcing itself.</p>



<h2 class="wp-block-heading">What ADOT actually buys you</h2>



<p class="wp-block-paragraph">ADOT is not a separate protocol or agent. It&#8217;s a downstream build of the upstream OpenTelemetry Collector plus AWS-flavoured SDKs, tested by AWS and covered by AWS Support. The config syntax is the upstream syntax, so a config written for vanilla OTel runs on ADOT and vice versa. The difference is which components are compiled in: ADOT ships the X-Ray exporter, the CloudWatch EMF exporter, the SigV4 extension, and ECS metric receivers already there.</p>



<p class="wp-block-paragraph">So the choice is narrower than it looks. Take ADOT for a build someone else validates and supports. Take upstream Contrib if you need a component ADOT hasn&#8217;t bundled, or you run one collector image across AWS, another cloud, and bare metal. Neither choice touches your instrumentation, which is the entire point.</p>



<h2 class="wp-block-heading">Decision one: where the collector runs</h2>



<p class="wp-block-paragraph">Three shapes, trading the same three things: blast radius, cost, and how much processing happens before data leaves your network.</p>



<h3 class="wp-block-heading">Sidecar</h3>



<p class="wp-block-paragraph">One collector container per ECS task or pod. The application talks to <code>localhost</code>, so no service discovery and no network hop to get wrong, and failure stays contained to one workload. The cost: you pay for that container everywhere, batches stay small because batching is per-instance, and any config change is a redeploy of every task.</p>



<h3 class="wp-block-heading">Agent plus gateway</h3>



<p class="wp-block-paragraph">A light collector per node (a DaemonSet on EKS) forwarding to a small pool of gateways. This is the shape I reach for first on anything past a handful of services. The agent does host-level enrichment, the gateway does the expensive work: large batches, tail sampling, redaction, fan-out. Config changes hit the gateway only.</p>



<p class="wp-block-paragraph">The catch: a gateway pool is a thing you now operate. It needs autoscaling and its own alerting, and undersized it starts refusing data at exactly the moment you have an incident and volume spikes.</p>



<h3 class="wp-block-heading">No collector at all</h3>



<p class="wp-block-paragraph">The ADOT SDKs can export straight to the CloudWatch OTLP endpoints, signing requests with credentials already available to the process. For a handful of Lambda functions this is the right answer: nothing to run, nothing to scale, no extra container in the cold start path. What you give up is control. Batching, retries, sampling policy, redaction, and backend routing all move into application processes and their environment variables, so changing any of them is a fleet redeploy rather than a config push. Good starting point, poor steady state.</p>



<h2 class="wp-block-heading">Decision two: which door into AWS</h2>



<p class="wp-block-paragraph">Two ways to hand telemetry to AWS, and they behave very differently.</p>



<p class="wp-block-paragraph">The older path uses AWS-specific exporters. <code>awsxray</code> converts OTLP spans into X-Ray segment documents and calls the X-Ray API. <code>awsemf</code> converts OTLP metrics into CloudWatch Embedded Metric Format and writes them as log events. Both work, both are well trodden, and both reshape your data into an AWS-native format on the way out.</p>



<p class="wp-block-paragraph">The newer path is native OTLP. CloudWatch exposes OTLP endpoints per signal, reached with the plain <code>otlphttp</code> exporter and a SigV4 signer. Same exporter you&#8217;d point at Grafana Cloud or Honeycomb, different URL and authenticator. That&#8217;s what &#8220;export anywhere&#8221; means in practice, and it&#8217;s why I default to it on new work.</p>



<p class="wp-block-paragraph">The endpoints follow a per-service pattern:</p>



<ul class="wp-block-list">
<li>Traces: <code>https://xray.&lt;region&gt;.amazonaws.com/v1/traces</code></li>

<li>Metrics: <code>https://monitoring.&lt;region&gt;.amazonaws.com/v1/metrics</code></li>

<li>Logs: <code>https://logs.&lt;region&gt;.amazonaws.com/v1/logs</code></li>
</ul>



<p class="wp-block-paragraph">Three constraints there will each cost you an afternoon if you don&#8217;t know them going in.</p>



<p class="wp-block-paragraph"><strong>HTTP only.</strong> No gRPC. If your collector exports over <code>otlp</code> on 4317 you can&#8217;t just change the URL, you have to switch exporters. Receiving gRPC from applications is fine, it&#8217;s the outbound leg that must be HTTP.</p>



<p class="wp-block-paragraph"><strong>SigV4 required.</strong> That means the <code>sigv4auth</code> extension, configured per signal because the signing service name differs: <code>xray</code>, <code>monitoring</code>, and <code>logs</code> respectively. Bearer tokens are an alternative for metrics and logs, but not for traces.</p>



<p class="wp-block-paragraph"><strong>Logs need headers, not just a URL.</strong> Target log group and stream travel in <code>x-aws-log-group</code> and <code>x-aws-log-stream</code> headers. Omit them and the request has nowhere to land.</p>



<p class="wp-block-paragraph">A working shape for traces and logs looks like this:</p>



<pre class="wp-block-code"><code>extensions:
  sigv4auth/traces:
    region: "us-east-1"
    service: "xray"
  sigv4auth/logs:
    region: "us-east-1"
    service: "logs"

exporters:
  otlphttp/traces:
    compression: gzip
    traces_endpoint: https://xray.us-east-1.amazonaws.com/v1/traces
    auth:
      authenticator: sigv4auth/traces

  otlphttp/logs:
    compression: gzip
    logs_endpoint: https://logs.us-east-1.amazonaws.com/v1/logs
    headers:
      x-aws-log-group: MyApplicationLogs
      x-aws-log-stream: default
    auth:
      authenticator: sigv4auth/logs

service:
  extensions: [sigv4auth/traces, sigv4auth/logs]
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [otlphttp/traces]
    logs:
      receivers: [otlp]
      exporters: [otlphttp/logs]</code></pre>



<p class="wp-block-paragraph">Note <code>traces_endpoint</code> and <code>logs_endpoint</code> rather than <code>endpoint</code>. This one bites people. The <code>endpoint</code> setting is a <em>base URL</em> and the exporter appends the signal path itself, so <code>endpoint: https://monitoring.us-east-1.amazonaws.com/v1/metrics</code> actually requests <code>/v1/metrics/v1/metrics</code>. The per-signal settings take a full path. If you&#8217;re getting 404s from a URL you&#8217;re certain is right, this is why.</p>



<p class="wp-block-paragraph">Also worth a comment in your config: upstream has renamed the component to <code>otlp_http</code> and <code>otlphttp</code> is now a deprecated alias scheduled for removal. Both work today.</p>



<h3 class="wp-block-heading">Traces need Transaction Search turned on first</h3>



<p class="wp-block-paragraph">This is the single most common reason a correctly configured trace pipeline produces nothing. The X-Ray OTLP endpoint requires Transaction Search to be enabled on the account, which redirects span ingestion into CloudWatch Logs:</p>



<pre class="wp-block-code"><code>aws xray update-trace-segment-destination --destination CloudWatchLogs</code></pre>



<p class="wp-block-paragraph">Spans then land in a log group named <code>aws/spans</code>, with a percentage indexed in X-Ray as trace summaries for search. The default index rate is one percent, enough to find traces while all spans stay queryable as structured logs. The caller needs <code>xray:UpdateTraceSegmentDestination</code> and <code>xray:UpdateIndexingRule</code> plus log group creation rights.</p>



<p class="wp-block-paragraph">Two consequences to plan for. Span ingestion is billed separately from log ingestion, so it&#8217;s a line item rather than a rounding error. And AWS recommends <code>always_on</code> sampling in the SDK on this path, because the indexing rule already handles reduction. Sample in both places and your service map goes patchy.</p>



<h3 class="wp-block-heading">The X-Ray SDK clock is running</h3>



<p class="wp-block-paragraph">The dates matter here, so plainly: the X-Ray SDKs and daemon entered maintenance mode on 25 February 2026 (security fixes only, no new instrumentation support), with end of support on 25 February 2027. The X-Ray <em>service</em> is fine and still gaining features. It&#8217;s the client libraries and the UDP daemon winding down. If you run <code>aws-xray-sdk</code> and a daemon sidecar, that is technical debt with a published expiry, and either the collector or the CloudWatch agent replaces the daemon.</p>



<h2 class="wp-block-heading">Decision three: metrics, temporality, and the cardinality bill</h2>



<p class="wp-block-paragraph">Metrics are where the silent drops live, because collector defaults and endpoint limits disagree.</p>



<p class="wp-block-paragraph">The metrics endpoint caps a single request at 1 MB uncompressed and 1,000 datapoints, counted across resource, scope, and metric levels combined. The upstream batch processor&#8217;s default is far larger. Leave it alone and you build oversized requests, and the response to an oversized or partially invalid request is not always a clean failure: it can come back <code>200</code> with some metrics accepted and others rejected or throttled. So set the batch size deliberately. AWS&#8217;s own examples use a conservative value:</p>



<pre class="wp-block-code"><code>processors:
  batch:
    send_batch_size: 200
    timeout: 10s</code></pre>



<p class="wp-block-paragraph">The other limits shape attribute design more than config:</p>



<ul class="wp-block-list">
<li>150 labels maximum across resource, scope, and datapoint attributes per datapoint</li>

<li>40 KB combined label and value size per series per datapoint</li>

<li>One million new series creatable per ten-minute window, per account</li>

<li>Timestamps no more than ten minutes in the future or fourteen days in the past</li>
</ul>



<p class="wp-block-paragraph">That new-series ceiling catches teams out. Attach a request ID, a session ID, or a raw URL path to a metric attribute and every request mints a fresh series. You&#8217;ll hit a million faster than you expect, and it presents as metrics randomly going missing rather than a quota error. Unbounded identifiers belong on spans and logs. Metric attributes should be values you could enumerate on a whiteboard.</p>



<p class="wp-block-paragraph">Temporality is the other conscious decision. The OpenTelemetry SDK spec defaults to cumulative, while CloudWatch&#8217;s metric model is delta-shaped: it wants what happened this period, not the total since your process booted. Counters that look like ever-climbing staircases instead of rates are that mismatch. The <code>cumulativetodelta</code> processor converts in the pipeline, keeping the decision in the collector rather than scattered across SDK environment variables in every service.</p>



<p class="wp-block-paragraph">Decide once and centrally either way. Mixed temporality across one account produces dashboards that are subtly wrong for months before anyone notices. And once volume climbs, pointing a cost tool such as Vantage or CloudZero at the CloudWatch line items earns its setup time, because telemetry spend grows in steps nobody approved.</p>



<h2 class="wp-block-heading">Decision four: a second backend without a second instrumentation</h2>



<p class="wp-block-paragraph">Here&#8217;s the payoff. A collector pipeline holds multiple exporters, and adding one is a config change:</p>



<pre class="wp-block-code"><code>service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlphttp/traces, otlphttp/vendor]</code></pre>



<p class="wp-block-paragraph">The same spans now reach CloudWatch and Grafana Cloud, Honeycomb, Datadog, or a self-hosted Tempo, with no application redeploy and no second agent. That&#8217;s the cashable value of instrumenting once against an open protocol: the switching cost of a backend drops from a migration project to a pull request.</p>



<p class="wp-block-paragraph">Be honest about the price. You pay ingest twice, and egress from the gateway is real money at volume. Collector memory scales with exporter queue count, so a fan-out gateway needs headroom. And if one backend slows, backpressure can reach the pipeline feeding the other, which argues for separate pipelines per destination when their reliability differs.</p>



<p class="wp-block-paragraph">It works in reverse too. Because the metrics and logs endpoints accept bearer tokens, a machine with no AWS credentials at all can ship into CloudWatch: a CI runner, another cloud, or a VPS at a provider like Contabo or InterServer running part of your stack. One collector config, one destination, regardless of who owns the hardware. Traces are the exception and still require SigV4.</p>



<h2 class="wp-block-heading">Why OpenTelemetry on AWS fails quietly</h2>



<p class="wp-block-paragraph">Most breakages here don&#8217;t throw. How to recognise the common ones:</p>



<h3 class="wp-block-heading">Data is missing but nothing errors</h3>



<p class="wp-block-paragraph">Almost always a limit breach inside an accepted request. Check batch sizes against the per-signal caps first, then attribute counts. Turn on the collector&#8217;s internal telemetry and compare the exporter&#8217;s sent counters against its failed counters. If sent looks healthy and data is still missing, the loss is happening server-side after acceptance, which narrows it to limits.</p>



<h3 class="wp-block-heading">403 with a signature mismatch</h3>



<p class="wp-block-paragraph">A SigV4 problem, not an IAM problem. The usual cause is the wrong signing service name in the extension, since <code>xray</code>, <code>monitoring</code>, and <code>logs</code> are not interchangeable. If you sign requests yourself rather than using the extension, note that the traces endpoint is stricter about which headers land in the signed set, so sign a minimal stable set rather than everything the HTTP client added.</p>



<h3 class="wp-block-heading">Backfilled data vanishes</h3>



<p class="wp-block-paragraph">Every endpoint enforces a timestamp window, and fourteen days in the past is the outer edge for all three signals. Replaying an old queue past that boundary gets rejected. Not a bug, but it looks like one when the replay job appears to succeed and produces nothing.</p>



<h3 class="wp-block-heading">Traces arrive but the service map is empty</h3>



<p class="wp-block-paragraph">Usually a missing or inconsistent <code>service.name</code> resource attribute, or double sampling between the SDK and the indexing rule. Confirm what&#8217;s actually leaving the collector before hunting in the console: add the <code>debug</code> exporter to a copy of the pipeline, set <code>verbosity: detailed</code>, and read the resource attributes on real spans.</p>



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



<ul class="wp-block-list">
<li>Pointing an OTLP gRPC exporter at a CloudWatch endpoint. They&#8217;re HTTP only, and the error won&#8217;t say so clearly.</li>

<li>Using <code>endpoint</code> with a full signal path instead of the per-signal <code>traces_endpoint</code>, <code>metrics_endpoint</code>, or <code>logs_endpoint</code>.</li>

<li>Leaving the batch processor at its default size on the metrics pipeline.</li>

<li>Sampling in the SDK <em>and</em> relying on Transaction Search indexing rules, halving visibility twice over.</li>

<li>Putting request IDs, user IDs, or raw paths into metric attributes.</li>

<li>Running a gateway pool with no alerting on the collector&#8217;s own health, so saturation is invisible until you need it.</li>
</ul>



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



<ul class="wp-block-list">
<li>Keep instrumentation vendor-neutral. Plain OpenTelemetry SDK APIs, with the collector owning every AWS-specific decision.</li>

<li>Set <code>service.name</code>, <code>service.version</code>, and <code>deployment.environment</code> as resource attributes everywhere. Almost every correlation feature depends on them.</li>

<li>Alert on exporter failure counters and queue depth, and route those alerts somewhere that doesn&#8217;t depend on the pipeline being healthy.</li>

<li>Version the collector config in Git and deploy it like application code, with a staging pipeline you can break safely.</li>

<li>Redact in a processor before export. Once telemetry reaches a backend, removing it is a support ticket.</li>

<li>Test a second exporter early, even to a throwaway account. A vendor migration is the wrong time to learn the fan-out path doesn&#8217;t work.</li>
</ul>



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



<h3 class="wp-block-heading">Do I still need a collector if CloudWatch accepts OTLP directly?</h3>



<p class="wp-block-paragraph">Not to get data in, no. You need one to control what happens before it leaves: batching to fit endpoint limits, tail sampling, redaction, and fan-out. Small serverless estates can reasonably skip it. Anything with a dozen services will want one.</p>



<h3 class="wp-block-heading">Can I send OTLP to CloudWatch over gRPC?</h3>



<p class="wp-block-paragraph">No. The CloudWatch OTLP endpoints are HTTP 1.1 only, accept binary or JSON payloads, and support gzip or no compression. Your collector can still receive gRPC from applications, it just can&#8217;t forward over it.</p>



<h3 class="wp-block-heading">What&#8217;s the difference between ADOT and the upstream collector?</h3>



<p class="wp-block-paragraph">Same codebase, different build. ADOT is AWS&#8217;s tested distribution with AWS components bundled and AWS Support behind it; upstream Contrib carries a wider component set and moves faster. Configuration is compatible either way, so it&#8217;s a support and packaging choice, not an architectural one.</p>



<h3 class="wp-block-heading">Do I have to enable Transaction Search to send traces?</h3>



<p class="wp-block-paragraph">For the X-Ray OTLP endpoint, yes. It&#8217;s a prerequisite, and enabling it routes span ingestion through CloudWatch Logs. Using the <code>awsxray</code> exporter against the classic X-Ray API instead doesn&#8217;t require it, but you give up the span-level analytics Transaction Search provides.</p>



<h3 class="wp-block-heading">Can I ship telemetry to CloudWatch from outside AWS?</h3>



<p class="wp-block-paragraph">For metrics and logs, yes, using bearer token authentication instead of SigV4, which removes the need for AWS credentials on the host. Traces still require SigV4. Never hardcode the token in the config; read it from a mounted secret file or an injected environment variable.</p>



<h3 class="wp-block-heading">Is it urgent to migrate off the X-Ray SDK?</h3>



<p class="wp-block-paragraph">Not an emergency, but it&#8217;s on a clock: maintenance mode now, end of support 25 February 2027. Existing applications keep working, they just won&#8217;t get new library instrumentation. Plan the migration on your schedule rather than someone else&#8217;s.</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">A pipeline that returns <code>200</code> is not a pipeline that works. Judge every design decision in OpenTelemetry on AWS against one question: when this drops data, do I find out from a dashboard or from a colleague asking why a service vanished off the map?</p>



<p class="wp-block-paragraph">Size batches against the published limits, keep unbounded identifiers off metric attributes, decide temporality in one place, and instrument with plain OpenTelemetry so the export target stays a config line. Do that and &#8220;instrument once, export anywhere&#8221; stops being a slogan and becomes a property you can test.</p>



<h2 class="wp-block-heading">Need help with your OpenTelemetry pipeline on AWS?</h2>



<p class="wp-block-paragraph">Most of this work is unglamorous and specific, which is why it gets deferred. Things I can help with:</p>



<ul class="wp-block-list">
<li>Auditing an ADOT or upstream collector config for silent drops, oversized batches, and limit breaches</li>

<li>Designing collector topology for ECS, EKS, or Lambda, including gateway sizing and autoscaling</li>

<li>Migrating X-Ray SDK and daemon workloads to OpenTelemetry ahead of end of support</li>

<li>Cutting telemetry spend through sampling policy, cardinality control, and attribute pruning</li>

<li>Dual export to CloudWatch and a third-party backend, so a future switch is a config change</li>

<li>Meta-monitoring that tells you the pipeline is broken before your users do</li>
</ul>



<p class="wp-block-paragraph">Send me a collector config, an exporter log, or a screenshot of the gap in your dashboard and I&#8217;ll tell you what I&#8217;d look at first.</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/opentelemetry-on-aws-adot/">Instrument Once, Export Anywhere: OpenTelemetry on AWS With ADOT</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
