Stream Observability and Lag Monitoring
The characteristic failure of a telemetry pipeline is not a crash. A crash is loud, it pages someone, and it is fixed in minutes. The failure that costs a month of data is a consumer that keeps running while processing slightly less than it receives — no errors, no restarts, just a dashboard whose newest point creeps from thirty seconds old to three hours old over a fortnight, and a regulatory report built from windows that were still open when they were read. Detecting that requires instrumentation that most pipelines add only after it has already happened. This stage of the Real-Time Stream Processing & Spatial Analytics pipeline defines the signals that make a slow stall as visible as a crash, and — just as important — that distinguish a slow pipeline from a quiet one.
The distinction matters more here than in most domains. In a web system, falling traffic is a business problem. In an environmental network, falling traffic might be a broken uplink, a flat battery, a firmware rollout, or genuinely nothing happening — and each has a different owner.
Prerequisites
- Python 3.11 with
# python 3.11 · confluent-kafka==2.4.0 · prometheus-client==0.20.0 · psycopg[binary]==3.1.18. - A metrics backend with a scrape or push path. The examples emit Prometheus metrics; any system that supports labelled counters, gauges and histograms works identically.
- Event-time timestamps on every record. Lag in seconds is meaningless without a trustworthy event time, so the timestamp normalization stage is a hard dependency.
- A device registry. Per-sensor freshness needs the list of sensors that are supposed to be reporting, which comes from device registry metadata, not from the stream itself. A stream cannot tell you about a sensor it has never heard from.
Step-by-Step Workflow
Step 1 — Record Three Clocks on Every Record
One timestamp cannot distinguish a slow sensor from a slow pipeline. Three can.
# python 3.11 · confluent-kafka==2.4.0 · prometheus-client==0.20.0
from dataclasses import dataclass
from datetime import datetime, timezone
@dataclass(frozen=True)
class Timed:
"""A record carrying all three clocks, so latency can be attributed."""
event_time: datetime # when the sensor took the measurement
ingest_time: datetime # when the broker accepted it
process_time: datetime # when this stage handled it
@property
def transport_latency(self) -> float:
return (self.ingest_time - self.event_time).total_seconds()
@property
def pipeline_latency(self) -> float:
return (self.process_time - self.ingest_time).total_seconds()
transport_latency belongs to the radio, the gateway and the network; pipeline_latency belongs to
you. Reporting only their sum guarantees that every latency conversation starts with an argument
about whose problem it is.
Complexity: O(1) per record; two subtractions.
Step 2 — Export Lag in Both Units
from prometheus_client import Counter, Gauge, Histogram
LAG_SECONDS = Gauge(
"stream_event_lag_seconds",
"Now minus the event time of the last processed record",
["topic", "partition"],
)
LAG_MESSAGES = Gauge(
"stream_consumer_lag_messages",
"Broker high watermark minus committed offset",
["topic", "partition", "group"],
)
STAGE_LATENCY = Histogram(
"stream_stage_latency_seconds",
"Per-stage processing latency",
["stage"],
buckets=(0.001, 0.005, 0.02, 0.1, 0.5, 2, 10, 60),
)
def observe(record: Timed, topic: str, partition: int) -> None:
now = datetime.now(timezone.utc)
LAG_SECONDS.labels(topic=topic, partition=str(partition)).set(
(now - record.event_time).total_seconds()
)
STAGE_LATENCY.labels(stage="enrich").observe(record.pipeline_latency)
Labelling by partition rather than aggregating is what lets you see the failure mode that matters most in Kafka: one partition falling behind while the topic-level average looks fine. A single stalled partition is invisible in an aggregate and obvious per partition.
Complexity: O(1) per record; the cardinality cost is partitions × topics, which stays small.
Step 3 — Count What You Drop, With a Reason
Every stage that can shed load must say so. A drop that is not counted is data loss that looks like a sensor outage.
DROPPED = Counter(
"stream_records_dropped_total",
"Records deliberately discarded, by reason",
["stage", "reason"],
)
DROP_REASONS = ("queue_full", "past_watermark", "schema_invalid", "unknown_device")
def drop(stage: str, reason: str) -> None:
assert reason in DROP_REASONS, f"undeclared drop reason: {reason}"
DROPPED.labels(stage=stage, reason=reason).inc()
The assertion looks pedantic and is the reason the metric stays useful. Free-form reason strings grow to dozens of near-duplicates within a year, and the dashboard built on them stops meaning anything.
Complexity: O(1). The reason label must be low-cardinality — never include a device identifier.
Step 4 — Publish Per-Sensor Freshness
Freshness is the only signal that distinguishes a quiet network from a broken one, and it must be computed against the registry rather than the stream.
FRESHNESS = Gauge(
"sensor_seconds_since_last_reading",
"Age of the newest accepted reading for each registered device",
["site_id"],
)
FRESHNESS_SQL = """
SELECT d.site_id,
EXTRACT(EPOCH FROM (now() - max(r.observed_at))) AS age_s
FROM deployment d
LEFT JOIN reading r
ON r.device_id = d.device_id
AND r.observed_at > now() - interval '2 days'
WHERE d.valid @> now()
GROUP BY d.site_id
"""
def publish_freshness(conn) -> None:
"""Run on a schedule — this is a registry question, not a stream question."""
with conn.cursor() as cur:
cur.execute(FRESHNESS_SQL)
for site_id, age_s in cur.fetchall():
FRESHNESS.labels(site_id=site_id).set(float(age_s) if age_s is not None else 1e6)
The LEFT JOIN is the point: a device with no readings at all yields NULL, which becomes a very
large age rather than disappearing from the result. A sensor that has never reported is exactly the
one you most need to see.
Complexity: O(devices) per run, on a schedule measured in minutes. Group by site rather than by device to keep metric cardinality bounded; per-device detail belongs in a query, not in a gauge.
Step 5 — Alert on the Derivative
A lag threshold produces either constant noise or silence, because acceptable lag varies with load. What is never acceptable is lag that keeps rising.
# Prometheus rule, expressed here as the expression only
LAG_RISING = """
max by (topic) (stream_event_lag_seconds) > 120
and
deriv(max by (topic) (stream_event_lag_seconds)[10m:]) > 0
"""
Both conditions together: absolutely behind, and getting worse. A pipeline recovering from a deployment satisfies the first and not the second, and it should not page anyone.
Complexity: evaluated by the metrics backend; no pipeline cost.
Configuration and Tuning
Signals, and what each one is for
| Signal | Unit | Alert on | Answers |
|---|---|---|---|
| Event lag | seconds | rising, above threshold | how stale is the output |
| Consumer lag | messages | trend only | how much work is queued |
| Stage latency | seconds (p99) | sustained increase | which stage got slower |
| Drop counter | count/s by reason | any nonzero for some reasons | what is being shed, and why |
| Sensor freshness | seconds per site | above 3× cadence | is a site dark |
| Arrival rate | records/s | drop against baseline | are sensors reporting at all |
Alert thresholds by pipeline class
| Pipeline | Event-lag warn | Event-lag page | Freshness page | Evaluation window |
|---|---|---|---|---|
| Live operations dashboard | 60 s | 300 s | 3× cadence | 5 min |
| Regulatory hourly reporting | 10 min | 45 min | 6× cadence | 15 min |
| Daily archival batch | 2 h | 8 h | 24 h | 1 h |
| Alerting on exceedances | 30 s | 120 s | 2× cadence | 2 min |
The evaluation window matters as much as the threshold. A two-minute window on a feed whose sensors report every fifteen minutes will fire on every ordinary quiet period.
Validation
- Inject a known delay. Hold a batch for 90 seconds in a staging consumer and confirm event lag reports approximately 90 seconds. If it reports the processing time instead, the metric is reading the wrong clock — a mistake that survives indefinitely because both numbers look plausible.
- Kill one partition’s consumer. Lag for that partition must rise while the others stay flat. If the dashboard shows a mild rise in an average, the aggregation is hiding exactly the failure the metric exists to catch.
- Stop a sensor. Freshness for its site must rise past the threshold; consumer lag must not move. This is the test that proves the two signals are independent.
- Fill a bounded queue. The
queue_fulldrop counter must increment and the pipeline must stay up. A drop counter that stays at zero while data disappears means the shedding path bypasses the instrumentation. - Cardinality check. Count active series after a week. If it grows without bound, a label is carrying a device identifier or a timestamp.
Failure Modes and Edge Cases
Event lag computed from processing time. The most common instrumentation bug in this stage: the
gauge is set from now() - process_time, which is approximately zero at all times, including while
the pipeline is hours behind. Compute it from event time, and validate it by injecting a delay.
Lag that looks perfect because the consumer is skipping. A consumer configured with
auto.offset.reset=latest that restarts after a long outage jumps to the head of the topic. Lag
goes to zero instantly, and the skipped messages are simply never processed. Alert on offset
discontinuities as well as on lag.
Freshness metrics with per-device labels. A 20 000-sensor fleet becomes 20 000 time series, each with its own retention, and the metrics backend becomes the largest component of the platform. Aggregate to site and keep per-device detail queryable from the database.
Alerting on absolute drop counts. Drops scale with traffic; a rate is comparable and a count is
not. Alert on rate(...[5m]) above a proportion of throughput.
Clock skew on the collector. Event lag is a difference between the collector’s clock and the sensor’s. If the collector drifts, every lag figure drifts with it — and a lag that goes negative is the signature. Assert non-negative lag and alert if it is violated.
Instrumentation that shares a thread with processing. A metrics push that blocks on a network call inside the message handler adds its latency to every record and, when the metrics backend is down, stalls the pipeline. Export by scrape, or push from a separate thread with a bounded queue.
Integration
Observability sits alongside every other stage rather than inside one. It consumes event times from timestamp normalization, the registry from device metadata management, and drop counts from the backpressure stage. Its outputs drive three decisions: whether to scale consumers, whether to widen the watermark grace period, and whether a site needs a field visit.
The two guides below go deeper on the parts with the most implementation detail: end-to-end latency attribution and consumer lag alerting.
FAQ
What is the single most useful metric for a sensor stream?
Time lag on the consumer: the difference between now and the event time of the last record processed. It answers the only question an operator actually has — how stale is what I am looking at — and it degrades gracefully, rising smoothly as the pipeline falls behind rather than flipping between healthy and broken.
Why is message lag not enough on its own?
Because its meaning changes with traffic. A backlog of 10 000 messages is four minutes of work at peak and forty minutes overnight. Message lag is the right unit for capacity planning — it tells you how much work is queued — and the wrong unit for alerting, where what matters is how far behind real time the output has fallen.
How do I tell a stalled pipeline from a network of dead sensors?
Compare consumer lag against arrival rate. A stalled pipeline shows rising lag with a steady arrival rate; dead sensors show falling arrival rate with lag at zero, because the consumer has nothing left to process. Without both signals, the two look identical from the dashboard: no fresh data.
Should every stage emit metrics, or just the endpoints?
Every stage that can drop, delay or transform a record. Endpoint-only instrumentation tells you that the output is late and nothing about which of six stages caused it, so the first thirty minutes of every incident is spent adding the instrumentation you should already have had.
What retention do these metrics need?
Thirteen months at reduced resolution. Environmental monitoring is seasonal, and the question “is this lag normal for a February morning” cannot be answered from a fortnight of data. Keep full resolution for a week, five-minute rollups for a quarter and hourly beyond that.
Related
- Real-Time Stream Processing & Spatial Analytics for Environmental IoT — the stream processing section this instrumentation covers
- Measuring End-to-End Latency in a Sensor Pipeline — attributing latency to a stage using the three clocks
- Alerting on Kafka Consumer Lag for Sensor Topics — turning the lag signal into an alert that fires once, correctly
- Backpressure Handling in Python IoT Streams — the mechanism that responds to what this stage measures
- Out-of-Order Event Handling and Watermarks — why event-time skew is a design parameter rather than a fault
Articles in This Section
Measuring End-to-End Latency in a Sensor Pipeline
Attribute environmental telemetry latency to the stage that caused it — the three clocks, per-stage histograms, percentile choice, and why the mean is the one statistic that will mislead you.
Alerting on Kafka Consumer Lag for Sensor Topics
Build a consumer-lag alert for environmental telemetry that fires on real stalls and stays quiet through deploys — per-partition evaluation, rate-of-change conditions, and the seasonal baselines a fixed threshold cannot express.