Instrumenting a Wayfinding API with OpenTelemetry
The mechanics behind Wayfinding API Observability: where to put spans, which attributes are safe to aggregate on, and the sampling policy that keeps the interesting requests without storing all of them.
The Span Tree
A routing request has three stages that can be slow for unrelated reasons, so each gets its own span:
snap.originis a spatial index query plus a projection. When it is slow, the index is wrong-sized or the tolerance is admitting too many candidates.graph.solveis the shortest-path search. When it is slow, the graph grew, or the request fell through to a fallback tier that re-solves on a modified copy.instructionsturns a node sequence into steps. When it is slow, the route is unusually long — which is itself worth knowing.
Attributes go on the root span so a trace can be filtered without expanding it, and the tier goes on the solve span because that is where it is decided. The instruction gap — the largest distance between consecutive turn instructions — is recorded here rather than computed later, because the generator already knows it and nothing downstream does.
Minimal Working Example
import logging
import time
from opentelemetry import metrics, trace
from opentelemetry.trace import Status, StatusCode
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
tracer = trace.get_tracer("wayfinding.api")
meter = metrics.get_meter("wayfinding.api")
h_route = meter.create_histogram("route.duration", unit="s")
c_tier = meter.create_counter("route.tier")
h_gap = meter.create_histogram("route.instruction_gap", unit="m")
c_unsnapped = meter.create_counter("position.unsnapped")
def handle_route(req) -> dict:
"""Serve one routing request with the spans and metrics the service is judged on."""
dims = {"profile": req.profile, "building": req.building, "map_version": req.map_version}
started = time.perf_counter()
with tracer.start_as_current_span("route") as root:
for key, value in dims.items():
root.set_attribute(f"route.{key}" if key == "profile" else key, value)
with tracer.start_as_current_span("snap.origin") as snap_span:
origin = snap(req.origin, req.building, level=req.level)
snap_span.set_attribute("snap.refused", origin is None)
if origin is None:
c_unsnapped.add(1, dims)
root.set_status(Status(StatusCode.ERROR, "origin could not be snapped"))
raise OriginNotOnGraph(req.origin)
with tracer.start_as_current_span("graph.solve") as solve_span:
result = router.solve(origin, req.destination, profile=req.profile)
solve_span.set_attribute("route.tier", result.tier)
solve_span.set_attribute("route.length_m", round(result.length, 1))
with tracer.start_as_current_span("instructions"):
steps = make_instructions(result.path)
gap = max((s.distance_m for s in steps), default=0.0)
h_gap.record(gap, dims)
c_tier.add(1, {**dims, "tier": str(result.tier)})
h_route.record(time.perf_counter() - started, dims)
return {"route": result.payload, "steps": steps,
"profile_applied": req.profile, "tier": result.tier,
"map_version": req.map_version}
Note that map_version appears in the response as well as in the telemetry. That is what turns a
user report into a query: someone saying “it sent me the wrong way this morning” can be resolved
against a specific build if the client recorded which one it was talking to.
Attributes and Cardinality
Metrics are aggregated across every combination of their dimensions, so each dimension multiplies the series count. Four low-cardinality dimensions — profile (4), tier (5), building (~300) and map version (~50 a year) — could in principle produce 300,000 series, but in practice a building only sees a handful of versions and profiles, so the realised count is a few thousand. That is comfortable.
Adding one unbounded dimension is not. trace_id on a metric produces one series per request;
feature_id produces one per room per request. Both will be silently accepted by the SDK and will
arrive as an invoice.
The safe rule is that anything identifying a request or an object belongs on a span, and anything identifying a category can be a metric dimension. Spans are sampled, so an unbounded attribute on a span costs storage proportional to the sample rate rather than to the cardinality.
Sampling Policy
The policy that works is a low base rate plus targeted rules:
def should_sample(result, base_rate: float = 0.03) -> bool:
# Sample ordinary requests sparsely and interesting ones always.
if result.tier > 1: # any degradation is worth a full trace
return True
if result.seconds > 0.25: # anything slow, regardless of tier
return True
if result.snap_refused: # every refusal, they are rare and diagnostic
return True
return _deterministic_hash(result.trace_id) < base_rate
A 3% base rate stores about 4,500 traces a week at 150k requests, and the targeted rules add roughly the same again — because tier-2-and-above requests are about 3% of traffic by design. The result captures the overwhelming majority of the requests anyone would want to look at, for a fraction of the storage of full sampling.
The one thing to avoid is head-based sampling with no tail rules, which decides before the request runs and therefore cannot preferentially keep the slow ones. If the backend supports tail sampling, use it; if not, the deterministic-hash approach above with explicit rules gets most of the benefit.
Common Errors & Fixes
Traces stop at the API boundary. Context is not propagated into the worker or the graph service. Use the SDK’s context propagation rather than passing a trace id manually, and instrument outbound calls so the child spans attach to the right parent.
Metric cardinality explodes after a release. Someone added a useful-looking dimension. The fix is a lint in review: metric dimensions come from a fixed allow-list, and anything not on it is a span attribute.
Latency looks fine and users complain. The percentile is being computed across all profiles. Step-free routes are both slower and rarer, so they are invisible in an aggregate p95. Break every latency metric down by profile — that is why profile is a dimension.
The tier counter is always 1. The fallback tier is being recorded from the request rather than from the result, so it always reads as the tier that was attempted. Record it from the result, on the solve span, where the router actually decided it.
Integration Point
This instrumentation produces the raw material for everything in Wayfinding API Observability: the tier counter feeds the degradation share, the unsnapped counter feeds the map-quality band, and the instruction gap feeds route quality. The route quality guide turns those into the derived measures teams actually alert on.
Downstream, the same signals are what the
rollback trigger watches — which is why
map_version has to be a dimension rather than a log line. A trigger that cannot compare the
current version against the previous one has nothing to roll back to.
Frequently Asked Questions
How many spans should one request produce?
Three to five for a routing request, and the test is whether each one answers a distinct question. Snapping, solving and instruction generation fail for unrelated reasons and have unrelated latency profiles, so separating them means a slow request is attributable at a glance. Going finer — a span per graph relaxation, say — produces traces nobody reads and measurable overhead, since span creation is not free. Going coarser leaves you knowing only that the request was slow.
Should the client be instrumented too?
Yes for the trace id, cautiously for everything else. Having the client generate and send a trace id, and having the API echo the map version back, is what connects a user report to a specific request — that alone is worth doing and carries no privacy weight. Full client-side tracing of positioning and rendering is a larger undertaking with real privacy considerations, since position traces are location history, and is better kept as an opt-in diagnostic mode than as always-on telemetry.
What retention makes sense for wayfinding traces?
Two weeks for traces, a year for metrics, and longer for probe results. Traces answer questions about specific recent requests and are almost never consulted after a fortnight, while being the expensive part of the bill. Metrics are cheap and are used for release-over-release and seasonal comparisons, which need a year. The synthetic probe results deserve their own long retention despite being tiny, because a year of them shows which buildings regress repeatedly — which is an estates conversation rather than an engineering one.
Related
- Wayfinding API Observability — what these signals are used for.
- Measuring Indoor Route Quality in Production — the derived measures built on this instrumentation.
- Automating Rollback Triggers for Indoor Map Deploys — the consumer that acts on these metrics automatically.
This page is a companion to Wayfinding API Observability, part of the Production-Ready Indoor Map Deployment section.