Path changes
GET /api/targets/{target_id}/path-changes
Observiant compares each new traceroute with the previous ones from the same agent to the same target. When the route changes in a way that matters, it records a path change with a before-and-after summary. Path changes exist only for ICMP targets, because only they have traceroutes.
Kinds of change
kind | Recorded when |
|---|---|
as_path_changed | The sequence of autonomous systems changes and the new sequence appears in consecutive reports. A path that appears in a single report and then reverts, typical of ECMP load balancing, is not recorded. |
became_unreachable | The destination stops answering: the previous report reached it and the new one does not. |
became_reachable | The destination answers again after a report that did not reach it. |
hops_changed | The hop addresses change and the destination is affected at the same time. Affected means latency moves by at least 5 ms and at least 20%, or packet loss at the destination goes from zero to non-zero or back. |
One traceroute can produce more than one change, for example became_unreachable together with hops_changed. Hops that do not answer (???) count as missing information, not as a change. Hop changes without an effect on the destination are not recorded either, because they are usually load balancing or routers rate-limiting their ICMP replies. The first report after an agent restarts becomes the new baseline and does not produce a change.
Parameters
| Parameter | Description |
|---|---|
agent_label | Optional. Only changes seen by this agent. |
start | Optional. Earliest change time, Unix milliseconds. |
end | Optional. Latest change time, Unix milliseconds. |
limit | Optional. Maximum number of changes, 1–500. Default and maximum 500. |
resolve_names | Optional. true adds a hostnames map with the current reverse DNS name of each hop address in the response. |
Changes are returned oldest first. Without start and end, the oldest changes in the retention period are returned. When you give both, the range can be at most 30 days.
Request
- curl
- Python
END=$(date +%s000)
START=$(( END - 7 * 86400000 )) # last 7 days
curl --header "Authorization: Bearer $OBSERVIANT_API_KEY" \
"$OBSERVIANT_URL/api/targets/01M4MZ5TCN74PFRTXDKXYE4XWX/path-changes?start=$START&end=$END&resolve_names=true"
import time
end = int(time.time() * 1000)
response = session.get(
f"{base_url}/api/targets/01M4MZ5TCN74PFRTXDKXYE4XWX/path-changes",
params={"start": end - 7 * 86400 * 1000, "end": end, "resolve_names": "true"},
timeout=30,
)
response.raise_for_status()
data = response.json()
Response
{
"changes": [
{
"target_id": "01M4MZ5TCN74PFRTXDKXYE4XWX",
"agent_label": "sys-amsterdam",
"display_name": "Amsterdam",
"timestamp": 1791702011845,
"kind": "as_path_changed",
"prev_as_path": ["AS64500", "AS64501", "AS15169"],
"new_as_path": ["AS64500", "AS64502", "AS15169"],
"prev_hop_hosts": ["192.0.2.1", "198.51.100.17", "198.51.100.66", "???", "203.0.113.9", "8.8.8.8"],
"new_hop_hosts": ["192.0.2.1", "198.51.100.17", "198.51.100.66", "203.0.113.130", "203.0.113.141", "???", "8.8.8.8"],
"prev_hop_asns": ["", "AS64500", "AS64500", "", "AS64501", "AS15169"],
"new_hop_asns": ["", "AS64500", "AS64500", "AS64502", "AS64502", "", "AS15169"],
"prev_hop_count": 6,
"new_hop_count": 7,
"destination_reached_from": true,
"destination_reached_to": true,
"prev_destination_latency_ms": 2.64,
"new_destination_latency_ms": 9.87,
"prev_destination_loss_pct": 0,
"new_destination_loss_pct": 20
}
],
"hostnames": {
"198.51.100.17": "ae1-10.ams-core1.example.net",
"198.51.100.66": "be20.ams-edge2.example.net",
"8.8.8.8": "dns.google"
}
}
Every change carries the route before (prev_…) and after (new_…) it, whatever its kind, so you can explain a change without fetching the traceroutes:
| Field | Description |
|---|---|
target_id, agent_label, display_name | The target and the agent whose traceroutes changed. |
timestamp | Start time of the traceroute that confirmed the change, Unix milliseconds. Pass it to /mtr/nearest with the same agent_label to fetch that report. |
kind | One of the kinds of change. |
prev_as_path, new_as_path | Autonomous systems along the path, in order, without repeats. |
prev_hop_hosts, new_hop_hosts | Hop addresses in path order, ??? for hops that did not answer. |
prev_hop_asns, new_hop_asns | Autonomous system of each hop, aligned with the hop addresses. Empty strings where unknown. |
prev_hop_count, new_hop_count | Number of hops. |
destination_reached_from, destination_reached_to | Whether the destination answered before and after. |
prev_destination_latency_ms, new_destination_latency_ms | Average round-trip time at the destination, milliseconds. |
prev_destination_loss_pct, new_destination_loss_pct | Packet loss at the destination, percent. |
hostnames | Only with resolve_names=true. Maps hop addresses to their reverse DNS names. Omitted when no names were found. |
About hostnames:
- Names are looked up when you make the request, so they are current names, not the names at the time of the change.
- Lookups are limited to a few seconds per request. Addresses without a name, or whose lookup did not finish in time, are left out, so fall back to the address.
- Requesting names makes the response slower. Ask for them only when you display the change to a person.
An unknown target, or a target without changes in the range, returns an empty changes array.
Fetch more changes
A response holds at most 500 changes. If you receive exactly limit changes, repeat the request with start set to one millisecond after the last timestamp, and keep the same end.
Example: report recent route changes
This script prints the route changes of the last 24 hours for every ICMP target:
"""Print route changes of the last 24 hours for all ICMP targets."""
import os
import time
import requests
base_url = os.environ["OBSERVIANT_URL"]
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['OBSERVIANT_API_KEY']}"
end = int(time.time() * 1000)
start = end - 86400 * 1000
response = session.get(f"{base_url}/api/targets", timeout=30)
response.raise_for_status()
targets = [t for t in response.json() if t["protocol"] == "icmp"]
for target in targets:
response = session.get(
f"{base_url}/api/targets/{target['target_id']}/path-changes",
params={"start": start, "end": end},
timeout=30,
)
response.raise_for_status()
for change in response.json()["changes"]:
when = time.strftime("%Y-%m-%d %H:%M", time.gmtime(change["timestamp"] / 1000))
line = f"{when} UTC {target['label']} from {change['display_name']}: {change['kind']}"
if change["kind"] == "as_path_changed":
line += f" {' '.join(change['prev_as_path'])} -> {' '.join(change['new_as_path'])}"
print(line)
$ python route_changes.py
2026-10-11 07:00 UTC Google DNS from Amsterdam: as_path_changed AS64500 AS64501 AS15169 -> AS64500 AS64502 AS15169
2026-10-11 07:45 UTC Google DNS from Amsterdam: as_path_changed AS64500 AS64502 AS15169 -> AS64500 AS64501 AS15169