Measuring GTFS-Realtime Feed Staleness
Staleness is now - header.timestamp, using the FeedMessage header clock and never the HTTP response headers — a CDN will happily serve a forty-minute-old payload with a fresh Date. Set the alert threshold at about three times the publisher’s observed cadence, derived from the distinct header timestamps you have seen, rather than from a hard-coded number that suits one feed and not the next. Then watch for negative staleness, which means clock skew and invalidates every figure until it is fixed. This is the first of the four metrics in monitoring realtime feed quality.
Root Cause Analysis
The failure this metric exists to catch is a feed that keeps working and stops updating. The endpoint returns 200, the body is valid protobuf, the entity list is fully populated — and every vehicle in it is where it was half an hour ago. Nothing about the request failed, so uptime monitoring reports the feed as healthy while every prediction built on it is fiction.
Three things make this harder than a subtraction.
The obvious clocks are the wrong clocks. The HTTP Date header describes when the response was assembled, which for a cached payload is unrelated to when the data was generated. Last-Modified, where present, describes the cache entry. The moment you fetched it describes your own scheduler. Only header.timestamp — set by the producer when it built the message — describes the data.
The threshold is feed-specific. Agencies publish at wildly different rates, from every five seconds for a busy metro to every five minutes for a regional bus network. Ninety seconds of staleness is an incident on the first and entirely normal on the second. A hard-coded threshold is therefore either too noisy or too quiet, and usually both across a portfolio of feeds.
Clock skew is silent. If the monitoring host’s clock drifts thirty seconds fast, every staleness figure is thirty seconds too high and still looks plausible. The only tell is that a feed occasionally reports a negative age, which is impossible and points straight at the cause — and which code that clamps to zero will hide forever.
There is a fourth, subtler case: a fresh header over stale entities. The producer is running, generating a new message every fifteen seconds, and the vehicle positions inside it have not changed because the upstream AVL system is down. The header clock says everything is fine. Per-entity timestamps are the only thing that says otherwise.
Production-Ready Python Implementation
"""Staleness monitoring for a GTFS-Realtime feed."""
from __future__ import annotations
import logging
import statistics
import time
from collections import deque
from dataclasses import dataclass
from google.transit import gtfs_realtime_pb2
log = logging.getLogger("gtfs.rt.staleness")
STALENESS_MULTIPLIER = 3.0 # alert above this many publication intervals
FALLBACK_CADENCE_S = 60.0 # used until enough distinct timestamps are seen
MIN_SAMPLES_FOR_CADENCE = 4
CLOCK_SKEW_TOLERANCE_S = 5.0 # allow this much before calling it skew
HISTORY = 400
@dataclass(frozen=True)
class Snapshot:
fetched_at: float
header_timestamp: int
entity_timestamps: tuple[int, ...]
@property
def age_s(self) -> float:
return self.fetched_at - self.header_timestamp
@property
def median_entity_age_s(self) -> float | None:
if not self.entity_timestamps:
return None
return self.fetched_at - statistics.median(self.entity_timestamps)
class StalenessMonitor:
def __init__(self, feed_name: str):
self.feed_name = feed_name
self._history: deque[Snapshot] = deque(maxlen=HISTORY)
self._seen_headers: deque[int] = deque(maxlen=HISTORY)
def observe(self, payload: bytes, fetched_at: float | None = None) -> Snapshot:
fetched_at = time.time() if fetched_at is None else fetched_at
message = gtfs_realtime_pb2.FeedMessage()
message.ParseFromString(payload)
header_ts = int(message.header.timestamp)
stamps = []
for entity in message.entity:
if entity.HasField("vehicle") and entity.vehicle.timestamp:
stamps.append(int(entity.vehicle.timestamp))
elif entity.HasField("trip_update") and entity.trip_update.timestamp:
stamps.append(int(entity.trip_update.timestamp))
if header_ts == 0:
# Required by the spec; fall back to the entities so we report SOMETHING
# rather than an age of "now", which would look perfectly fresh.
if stamps:
header_ts = max(stamps)
log.warning("%s: header.timestamp is zero — using the newest entity "
"timestamp instead", self.feed_name)
else:
log.error("%s: no timestamp anywhere in the message; staleness is "
"unmeasurable for this poll", self.feed_name)
snapshot = Snapshot(fetched_at=fetched_at, header_timestamp=header_ts,
entity_timestamps=tuple(stamps))
self._history.append(snapshot)
if header_ts and (not self._seen_headers or self._seen_headers[-1] != header_ts):
self._seen_headers.append(header_ts)
return snapshot
def cadence_s(self) -> float | None:
"""The producer's real publication interval, from DISTINCT header stamps."""
stamps = sorted(set(self._seen_headers))
if len(stamps) < MIN_SAMPLES_FOR_CADENCE:
return None
gaps = [b - a for a, b in zip(stamps, stamps[1:]) if b > a]
return statistics.median(gaps) if gaps else None
def threshold_s(self) -> float:
return (self.cadence_s() or FALLBACK_CADENCE_S) * STALENESS_MULTIPLIER
def assess(self, snapshot: Snapshot) -> list[str]:
problems = []
if snapshot.age_s < -CLOCK_SKEW_TOLERANCE_S:
problems.append(
f"clock skew: header is {-snapshot.age_s:.0f}s in the FUTURE — every "
"staleness figure from this host is wrong until the clocks agree")
return problems # nothing else measured here can be trusted
limit = self.threshold_s()
if snapshot.age_s > limit:
problems.append(
f"stale: snapshot is {snapshot.age_s:.0f}s old, above the "
f"{limit:.0f}s threshold ({self.cadence_s() or FALLBACK_CADENCE_S:.0f}s "
f"cadence x {STALENESS_MULTIPLIER:g})")
entity_age = snapshot.median_entity_age_s
if entity_age is not None and entity_age > limit * 2:
problems.append(
f"fresh header over stale entities: median entity age "
f"{entity_age:.0f}s against a header age of {snapshot.age_s:.0f}s — "
"the producer is running but its data source may not be")
return problems
def frozen_for_s(self) -> float:
"""How long the header timestamp has not advanced, regardless of polling."""
if len(self._history) < 2:
return 0.0
newest = self._history[-1]
for snapshot in reversed(self._history):
if snapshot.header_timestamp != newest.header_timestamp:
return newest.fetched_at - snapshot.fetched_at
return newest.fetched_at - self._history[0].fetched_at
Step-by-Step Walkthrough
observe takes bytes, not a response object. Staleness is a property of the message, so the monitor never sees the HTTP layer and cannot be tempted to read a Date header. That separation is the point: everything it measures comes from what the producer actually said.
A zero header timestamp falls back to the newest entity timestamp, loudly. The field is required, but feeds omit it. Reporting an age of zero in that case would be the worst outcome, because a broken feed would look perfectly fresh. Using the newest entity stamp gives a lower bound on the age; logging at warning level makes clear the number is a substitute.
_seen_headers only records a value when it changes. Polling every 10 seconds against a producer publishing every 30 returns the same message three times. Appending only on change means the deque holds distinct publications, and the median gap between them is the real cadence rather than the polling interval.
Clock skew short-circuits everything. When the header is meaningfully in the future, assess returns immediately. Continuing would produce a staleness number, and that number would be wrong by exactly the skew — the kind of plausible-looking figure that gets trusted for months.
Entity age is compared against twice the header threshold. Individual entities are legitimately older than the message that carries them: a vehicle reporting every 30 seconds appears in a message generated 5 seconds ago with a timestamp 25 seconds old. Doubling the allowance leaves room for that while still catching a fleet that has stopped reporting entirely.
frozen_for_s measures the feed, not the poller. It walks back through history to the last time the header changed and returns the elapsed wall-clock time. That is the number to put in an alert, because “the feed has not updated in 22 minutes” is actionable in a way that “staleness 1,340 seconds” is not.
Verification and Output
def verify(monitor: StalenessMonitor) -> None:
history = list(monitor._history)
fetches = [s.fetched_at for s in history]
assert fetches == sorted(fetches), "polls recorded out of order"
headers = [s.header_timestamp for s in history if s.header_timestamp]
assert headers == sorted(headers), (
"header timestamps went backwards — the producer may be load-balanced "
"across nodes with different state")
cadence = monitor.cadence_s()
if cadence is not None:
assert cadence > 0, "derived a non-positive publication cadence"
assert monitor.threshold_s() >= cadence, "threshold below one publication interval"
Normal operation on a 30-second feed:
INFO gtfs.rt.staleness: mbta_vehicles: age 12s, cadence 30s, threshold 90s, 412 entities, median entity age 26s
The failure this page exists for:
WARN gtfs.rt.staleness: mbta_vehicles: stale: snapshot is 1347s old, above the 90s threshold (30s cadence x 3)
WARN gtfs.rt.staleness: mbta_vehicles: frozen for 1338s — the endpoint is answering but the data has not changed
And the one that only per-entity timestamps reveal:
WARN gtfs.rt.staleness: county_bus: fresh header over stale entities: median entity age 903s against a header age of 8s — the producer is running but its data source may not be
Gotchas and Edge Cases
- Producers that round their timestamp. Some emit whole minutes, which makes the derived cadence granular and can push a genuinely 30-second feed to a 60-second reading. The threshold multiplier absorbs it; a tighter multiplier would not.
- A restarted monitor.
cadence_sreturnsNoneuntil four distinct timestamps are seen, so the first minute or so usesFALLBACK_CADENCE_S. That is deliberate — better a rough threshold than an alert storm from a single-sample estimate. - Feeds behind an aggressive cache. The header timestamp will step in whole cache-TTL increments, which reads as a slower cadence than the producer’s own. That is the correct thing to measure: it is the age of the data you can see, which is what your predictions are built on.
- Daylight-saving transitions. POSIX timestamps are unaffected, which is one of the few places in GTFS work where the DST problem does not arise. Do not convert to local time to compute an age.
- Containers without NTP. The most common source of real skew. The clock-skew branch will fire intermittently as drift crosses the tolerance, which reads as a flapping alert until someone looks at the host clock.
Frequently Asked Questions
Why not use the HTTP Date or Last-Modified header?
Because they describe the response, not the data. A CDN can serve a forty-minute-old payload with a Date header from one second ago, and a producer can regenerate an identical message without any of its content being newer. Only header.timestamp says when the producer built the snapshot.
What if header.timestamp is zero or missing?
It is required, so treat its absence as a feed defect and fall back to per-entity timestamps if any exist. If nothing carries a timestamp, staleness cannot be measured at all — record that fact explicitly rather than reporting zero, which would look like a perfectly fresh feed.
Why deduplicate timestamps before deriving the cadence?
Because polling faster than the producer publishes returns the same message repeatedly. Counting those repeats as observations yields a median gap of zero and a staleness threshold of zero, which then flags every poll.
Can staleness be negative?
Only through clock skew. A header timestamp ahead of your own clock means one of the two machines is wrong, and every staleness figure computed against it is wrong by the same amount. Detect it and fix the clock rather than clamping the number to zero.
Related
- Alerting on Realtime Feed Outages — turning a stale reading into a page somebody should answer
- Polling GTFS-Realtime Feeds Efficiently — why the polling interval and the cadence are different numbers
- Up: Monitoring Realtime Feed Quality — the four metrics together
- Section: GTFS-Realtime Integration · Home