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

Attributing a rise in unsnapped fixes One diagnostic question with four outcomes. If the rise is confined to a single map version, the map moved and geometry changed under live users. If it spans all map versions, the cause is positioning — beacons, access points or a client release. If it is confined to one building, that building's infrastructure is the cause. If it is confined to one client version, the SDK regressed. The dimensions decide the owner, not the metric Unsnapped-fix rate has risen. Which side moved? the map version dimension answers it in one query one map version The map moved geometry changed under live users all versions Positioning beacons, APs, or a client release one building Infrastructure that building's beacons or APs one client version Client release a regression in the SDK

One shared metric, four owners. The unsnapped-fix rate is the seam between the map team and the positioning team, and without the version and building dimensions it is a number both can blame the other for.

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.

An unsnapped-fix regression traced to a single map publish A single curve of unsnapped-fix rate over eighteen hours. The rate sits steadily around 3.1 percent for the first six hours, jumps to 7.9 percent at hour eight when map version 44 reaches all traffic, holds between 8 and 8.6 percent for six hours, then returns to 3.3 percent immediately after a rollback to version 43 at hour sixteen. Positioning drifts, maps step 4 6 8 0 5 10 15 hours since the v44 publish unsnapped fixes (%) v44 reaches 100% of traffic rolled back to v43

The step is the diagnosis. A positioning degradation drifts; a map regression steps at a publish boundary and steps back at a rollback.

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

An alerting policy for positioning signals A table of six alert conditions. An unsnapped-fix rate above eight percent sustained for fifteen minutes pages the positioning on-call. Above five percent for two hours raises a ticket routed by map version. A no-fix rate above ten percent in one building for thirty minutes pages infrastructure. A beacon hint rate down by more than half over twenty-four hours raises a facilities ticket about batteries. Floor agreement below 95 percent over six hours raises a map ticket about level elevations. A ninetieth percentile first-fix delay above twelve seconds for an hour raises a positioning ticket. Six conditions, two pages, four tickets Condition Window Action Who unsnapped_fix_rate > 8% 15 min △ page positioning on-call unsnapped_fix_rate > 5% 2 h ticket map or positioning, by version no_fix_rate > 10% in a building 30 min △ page infrastructure beacon_hint_rate down > 50% 24 h ticket facilities — batteries floor_agreement < 95% 6 h ticket map — level elevations first_fix_delay p90 > 12 s 1 h ticket positioning Two conditions page and four raise tickets — the split is by how fast the situation degrades.

Only fast-degrading conditions page. A beacon whose battery is dying has been dying for months and will keep until Monday; a positioning outage across a campus will not.

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.

This page is a companion to Wayfinding API Observability, part of the Production-Ready Indoor Map Deployment section.