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.
Three clocks, and what the gap between each pair means Flow diagram of a reading stamped with event time at the sensor, ingest time at the broker and processing time at each stage, with the interval between each pair labelled by who owns it. Three clocks, and what the gap between each pair means event_time stamped by the sensor measurement instant ingest_time stamped by the broker transport latency process_time stamped per stage pipeline latency emit window closed end-to-end lag Reporting only the total guarantees every latency conversation starts with an argument about whose problem it is.
Transport latency belongs to the radio and the network; pipeline latency belongs to you. One timestamp cannot tell them apart.

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.

Reading the two signals together tells you which failure you have Matrix of four combinations of consumer lag and arrival rate, each mapping to a distinct diagnosis: healthy, stalled pipeline, dark sensors, and a recovering backlog. Reading the two signals together tells you which failure you have Consumer lag Arrival rate Diagnosis Normal operation flat, low at baseline healthy Rising lag, steady arrivals rising at baseline pipeline is behind Zero lag, falling arrivals zero falling sensors are dark Falling lag, high arrivals falling above baseline recovering — do not page
From a dashboard, rows two and three look identical — no fresh data. Only the pairing distinguishes a stalled consumer from a dead network.

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.

The failure this instrumentation exists to catch Line chart of event lag over a fortnight, creeping from thirty seconds to nearly three hours without any error, restart or crash — the slow stall that a threshold-only alert never fires on until the data is already stale. The failure this instrumentation exists to catch 0 10 20 30 day 0 4 8 11 14 crosses the freshness objective event lag (min) days event lag (minutes)
No stage errored on any of these days. The consumer simply processed slightly less than it received, which is invisible to everything except a lag metric.

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_full drop 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.


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.

Read guide

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.

Read guide