Alerting on Indoor Positioning Degradation
Part of Wayfinding API Observability. Positioning and the map fail in ways that look identical from the routing API, and the signal that separates them is one metric with the right dimensions on it.
The Unsnapped-Fix Rate Is the Seam
When a position cannot be snapped to the routing graph, one of two things is true: the position was wrong, or the graph was. Both produce the same server-side observation — a refused snap — and both teams can reasonably believe it is the other’s problem.
The dimensions settle it. If the rise is confined to a single map.version, the graph moved: a
publish shifted geometry under users who were standing in the same places as before. If it spans
every version in flight, positioning changed. If it is confined to one building.id, the
infrastructure in that building is the cause. If it tracks a client release, the SDK regressed.
The shape is diagnostic too. A map regression steps: the rate jumps when a version reaches full traffic and steps back on rollback. Positioning degradation drifts, because beacons die one at a time and radio environments change gradually.
Detecting a Dying Estate
Beacons fail silently. A coin cell lasts 18-30 months and its death removes a level hint, or a trilateration anchor, without any error anywhere — the system simply becomes slightly less accurate, indefinitely.
The detection is a rate comparison rather than a threshold, because the absolute hint rate depends on how many people walked past:
import logging
from dataclasses import dataclass
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class BeaconHealth:
beacon_id: str
seen_now: int
seen_baseline: int
@property
def ratio(self) -> float:
return self.seen_now / max(self.seen_baseline, 1)
def failing_beacons(current: dict[str, int], baseline: dict[str, int],
*, min_baseline: int = 40, alert_below: float = 0.35
) -> list[BeaconHealth]:
"""Beacons whose detection count has collapsed against their own 28-day baseline."""
if not baseline:
raise ValueError("no baseline: cannot distinguish a dead beacon from a quiet week")
out: list[BeaconHealth] = []
for bid, base in baseline.items():
if base < min_baseline:
continue # too rarely seen to judge
health = BeaconHealth(bid, current.get(bid, 0), base)
if health.ratio < alert_below:
out.append(health)
logger.warning("beacon %s at %.0f%% of baseline (%d vs %d)",
bid, health.ratio * 100, health.seen_now, base)
return out
Comparing each beacon against its own baseline rather than against its peers is what makes this
work in a building where footfall varies by an order of magnitude between the lobby and a plant
corridor. The min_baseline guard excludes beacons nobody walks past often enough to judge — those
need a physical audit rather than a metric.
An Alert Policy That Does Not Wake People Up
The split between paging and ticketing is by how fast the situation gets worse, not by how much it matters.
A positioning outage across a campus is fast: users are affected right now, and every minute is sessions that cannot start. It pages.
A beacon whose battery is dying has been dying for months, is one of dozens, and degrades accuracy by a fraction of a metre. It is a maintenance ticket, and paging for it teaches the on-call rota to ignore positioning alerts — which is the actual risk.
The two-hour window on the 5% unsnapped condition is deliberate. That level of degradation is worth attention and is frequently transient: a busy lobby, a large event, a temporary partition. Alerting after two hours filters the transients and still catches a genuine regression well inside a working day.
Common Errors & Fixes
Everything pages and nothing is fixed. Too many conditions at page severity. Move anything that degrades over days to a ticket, and keep paging for conditions where the next hour matters.
The unsnapped rate is high and nobody owns it. The metric lacks the map.version dimension, so
neither team can prove it is the other’s. Adding the dimension is a one-line change that resolves
most of the ambiguity, as
OpenTelemetry instrumentation
describes.
Beacon alerts fire every Monday. The baseline is a fixed window that includes weekends. Compare like with like — a 28-day trailing baseline for the same day type, or a ratio against the building’s overall footfall for the same period.
Floor agreement drops and positioning is blamed. Floor agreement compares the barometric track against radio level estimates, and it falls when the level elevation table is wrong just as readily as when a sensor is. Check the building’s storey pitch against the map before investigating the device, which the floor-level detection topic covers in more detail.
Integration Point
Every signal here comes from the instrumentation in Wayfinding API Observability plus two client-reported counters — beacon hint rate and first-fix delay — that the SDK sends with its position updates.
The alert policy connects to the rollback trigger at exactly one point: the unsnapped condition scoped to a single map version. That is the case where the correct response is automatic rather than human, because the previous version is known good and reverting is a pointer change. Everything else on the list needs a person, because the fix is in a building, a battery, or a client release rather than in a published artifact.
Frequently Asked Questions
Why is the unsnapped-fix rate the primary signal?
Because it is the only measurement that both teams’ failures pass through, which makes it the cheapest place to detect either. A positioning regression puts fixes further from the graph; a map regression moves the graph away from the fixes; both show up as refusals to snap. Other candidates are narrower — position accuracy is device-side and harder to collect, route failures conflate map topology with positioning, and error rates catch neither. One well-dimensioned metric that catches both classes is worth more than several that each catch one.
Should positioning alerts be per building or global?
Both, and the pair is what makes them diagnostic. A global alert catches a client release or a platform change and would be diluted to nothing if computed per building. A per-building alert catches an infrastructure failure — a switch replaced, beacons removed during a refit — that would be invisible in a global average across three hundred buildings. Running them at different thresholds is sensible too: a global rate of 5% is serious, while one building at 5% is often just a busy day.
How do I baseline a building that has just been surveyed?
Wait for a fortnight of traffic before enabling anything that compares against a baseline, and use absolute thresholds in the meantime. A newly surveyed building has no history, so every relative metric is undefined and every beacon looks like it might be dying. The practical arrangement is a commissioning period with absolute thresholds only — the unsnapped rate should be under 5%, the no-fix rate under 5% — after which the building joins the relative alerting once it has its own baseline.
Related
- Wayfinding API Observability — the signals this policy alerts on.
- Measuring Indoor Route Quality in Production — the route-quality half of the same instrumentation.
- Snapping Noisy Positions to the Routing Graph — the operation whose refusal rate this whole page is built on.
This page is a companion to Wayfinding API Observability, part of the Production-Ready Indoor Map Deployment section.