Monitoring an Area for Suspicious OSM Edits Jump to heading
If your product depends on one city being right, you want to know within the hour when somebody deletes half of it — and you want that signal without an alert for each of the two thousand ordinary edits that also happened.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
The stream gives changeset metadata — bounding box, user, editor, comment, counts of created, modified and deleted elements — on a minutely cadence. The work is turning that into a short ranked list.
Score, do not classify. A binary “suspicious or not” forces a threshold nobody can defend and produces arguments about individual cases. A score sorts the queue, and the reviewer works down it until the findings stop being interesting, which is self-calibrating in a way a threshold is not.
Most signals are weak alone and useful together. A large deletion count is normal for an import cleanup. A new account is normal for the great majority of contributors, who are simply new. A vague changeset comment means very little. All three at once, inside a small area, on features your product depends on, is a different matter.
Bias toward consequence, not intent. You are not adjudicating whether somebody meant harm; you are deciding what to look at. An accidental mass delete from a misconfigured editor is as damaging as a deliberate one and considerably more common, and a scoring system aimed at consequence catches both.
The most important design constraint is that the queue must stay short enough to be worked. A monitor producing forty items a day gets ignored within a fortnight, and the correct response to a long queue is to raise the bar rather than to expect more attention.
Runnable solution Jump to heading
from __future__ import annotations
import gzip
import logging
import re
import urllib.request
import xml.etree.ElementTree as ET
from collections.abc import Iterable, Iterator
from dataclasses import dataclass, field
from datetime import datetime, timezone
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.changeset.watch")
STREAM = "https://planet.openstreetmap.org/replication/changesets"
USER_AGENT = "area-edit-monitor/1.0 (ops@example.net)"
QUEUE_TARGET = 5 # items per day; raise the bar if it overflows
VAGUE_COMMENT = re.compile(r"^\s*(|\.|update|edit|fix|test|asdf)\s*$", re.I)
@dataclass(frozen=True)
class Box:
west: float
south: float
east: float
north: float
def overlaps(self, other: "Box") -> bool:
return not (other.east < self.west or other.west > self.east
or other.north < self.south or other.south > self.north)
@property
def area_deg2(self) -> float:
return max(self.east - self.west, 0) * max(self.north - self.south, 0)
@dataclass
class Changeset:
id: int
user: str
uid: int
created_at: str
closed_at: str | None
comment: str
editor: str
box: Box | None
created: int = 0
modified: int = 0
deleted: int = 0
@dataclass
class Score:
changeset: Changeset
points: float
reasons: list[str] = field(default_factory=list)
def fetch(sequence: int, stream: str = STREAM) -> Iterator[Changeset]:
text = f"{sequence:09d}"
url = f"{stream}/{text[0:3]}/{text[3:6]}/{text[6:9]}.osm.gz"
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(request, timeout=60) as response:
payload = gzip.decompress(response.read())
for element in ET.fromstring(payload).findall("changeset"):
tags = {t.get("k"): t.get("v") for t in element.findall("tag")}
box = None
if element.get("min_lon"):
box = Box(float(element.get("min_lon")), float(element.get("min_lat")),
float(element.get("max_lon")), float(element.get("max_lat")))
yield Changeset(
id=int(element.get("id")), user=element.get("user", "?"),
uid=int(element.get("uid", 0)), created_at=element.get("created_at"),
closed_at=element.get("closed_at"), comment=tags.get("comment", ""),
editor=tags.get("created_by", "unknown"), box=box,
created=int(element.get("num_changes", 0)))
def score(changeset: Changeset, watched: Box,
known_users: set[int], critical_hits: int = 0) -> Score:
"""Bias toward CONSEQUENCE, not intent.
An accidental mass delete from a misconfigured editor does the same damage
as a deliberate one and is considerably more common.
"""
result = Score(changeset, 0.0)
if changeset.deleted >= 500:
result.points += 4
result.reasons.append(f"{changeset.deleted:,} deletions")
elif changeset.deleted >= 100:
result.points += 2
result.reasons.append(f"{changeset.deleted:,} deletions")
if critical_hits:
result.points += min(4.0, critical_hits * 0.5)
result.reasons.append(f"touches {critical_hits} feature(s) we depend on")
# A new account is NOT suspicious by itself; it only adds weight to a
# changeset that already looks consequential.
if changeset.uid not in known_users and result.points > 0:
result.points += 1
result.reasons.append("editor not seen in this area before")
if VAGUE_COMMENT.match(changeset.comment) and result.points > 0:
result.points += 0.5
result.reasons.append("no meaningful changeset comment")
# A wide bounding box for few changes means edits scattered far apart,
# which is what a careless bulk operation looks like.
if changeset.box and changeset.box.area_deg2 > watched.area_deg2 * 4:
result.points += 1.5
result.reasons.append("extent far larger than the watched area")
return result
def watch(sequences: Iterable[int], watched: Box, known_users: set[int],
floor: float = 4.0) -> list[Score]:
queue: list[Score] = []
for sequence in sequences:
for changeset in fetch(sequence):
if changeset.box is None or not watched.overlaps(changeset.box):
continue
scored = score(changeset, watched, known_users)
if scored.points >= floor:
queue.append(scored)
queue.sort(key=lambda s: s.points, reverse=True)
logger.info("%d changeset(s) above the floor", len(queue))
for item in queue[:20]:
logger.info("%5.1f cs/%d by %s — %s", item.points, item.changeset.id,
item.changeset.user, "; ".join(item.reasons))
return queue
def calibrate(queue: list[Score], days: int, target: int = QUEUE_TARGET) -> float:
"""A queue nobody works through protects nothing. Raise the bar instead
of expecting more attention."""
per_day = len(queue) / max(days, 1)
if per_day <= target:
return 0.0
keep = sorted((s.points for s in queue), reverse=True)[:target * days]
logger.warning("%.1f items/day against a target of %d; suggest floor %.1f",
per_day, target, keep[-1] if keep else 0.0)
return keep[-1] if keep else 0.0
if __name__ == "__main__":
logger.info("score, rank, and keep the queue short enough to be worked")
Step-by-step walkthrough Jump to heading
- Filter by bounding box first. The stream is global and the overwhelming majority of changesets are irrelevant to you, so the cheapest possible test comes first.
- Score rather than classify. Ranking lets the reviewer decide where to stop, and it avoids a threshold argument about any individual changeset.
- Weight deletions heavily. Deletion is the hardest edit to notice downstream and the most expensive to recover from, which makes it the highest-value signal.
- Let account age and comment quality act as multipliers only. They add weight to something already consequential and should never trigger on their own — fairer, and more accurate.
- Treat a wide extent with few changes as a signal. Edits scattered across a large box are what a careless bulk operation looks like from the metadata alone.
- Check whether critical features were touched. This requires resolving the changeset’s elements against your own dependency list, and it is the single most informative signal available.
- Calibrate the floor from volume. If the queue exceeds what somebody will actually review, raise the bar; expecting more attention has never once worked.
- Identify yourself in the user agent. The stream is a shared resource, and an anonymous poller is a poor neighbour.
Verification Jump to heading
- A synthetic mass delete ranks first. Replay a historical large-deletion changeset and confirm it tops the queue.
- Ordinary mapping does not appear. Run a week of real data over a quiet area and confirm the queue is near empty.
- New accounts alone do not score. Confirm a small edit by a first-time editor produces nothing.
- The box filter is tight. Confirm changesets adjacent to but outside the area are excluded.
- Calibration responds. Feed a noisy period and confirm the suggested floor rises.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Queue too long to review | Floor set below what anyone will work | Calibrate the floor from observed volume |
| New contributors constantly flagged | Account age used as a standalone trigger | Make it a multiplier on an already-scoring changeset |
| Genuine vandalism missed | Only deletions scored | Add critical-feature overlap and extent signals |
| Irrelevant changesets reviewed | Bounding box too generous | Tighten the box, or test against the actual boundary |
| Monitor stops without notice | No heartbeat on the poller | Alert on the stream sequence failing to advance |
| Requests throttled | No identifying user agent | Identify the client and poll politely |
| Arguments about individual items | Binary classification | Score and rank; let the reviewer decide where to stop |
Specification reference Jump to heading
The changeset replication stream publishes minutely
.osm.gzfiles containing changeset metadata: identifier, user, timestamps, the bounding box of the changes, the number of changes and the changeset’s tags, includingcommentandcreated_by. The bounding box is present only for changesets that modified at least one element with a location. Element-level detail is not included and must be retrieved separately. See the OpenStreetMap changeset replication documentation and the changeset API.
Frequently Asked Questions Jump to heading
Should the monitor revert anything automatically?
No. Automated reverts are how a false positive becomes an edit war, and the community norms around reverting exist for good reasons — a revert is itself an edit, attributed to you, that another mapper will have to review. The monitor’s output is a queue for a person who can look at the changeset, read the comment, check imagery and, where necessary, message the editor. That path resolves far more cases than a revert does.
How do you decide which features are critical?
From what your product actually breaks without, which is usually a much shorter list than people expect: the boundary polygons your aggregates depend on, the road classes your routing uses, the named places your search resolves. Deriving it from your own schema rather than from importance in the abstract keeps it short and keeps the scoring sharp, and it is worth revisiting whenever the schema changes.
What is a polite polling interval?
Matching the stream’s own cadence is fine — a minutely file published every minute can be fetched every minute — and conditional requests using the file’s ETag or last-modified time cost the server almost nothing when there is nothing new. What is not fine is polling several times a minute hoping to be first, or fetching without identifying yourself. The stream is run for everybody and the etiquette is the same as for any shared service.
Should this run against a local database instead?
If you already replicate the area, comparing before and after states gives element-level detail the changeset metadata cannot, including exactly which features changed and how. That is strictly more informative and considerably more work. The changeset stream is the low-cost version that runs without any local data, and running both — metadata for the fast signal, local diff for the detail once something is flagged — is a good arrangement.
Related Jump to heading
- Changeset Analysis & Vandalism Detection — the parent topic.
- Generating an OSM Data Quality Report — presenting the queue so it gets worked.
- Continuous QA for OSM Pipelines — the gate that catches what reaches your output.
- Replication Monitoring & Lag Alerting — the heartbeat pattern for this poller.
- Incremental Updates for Derived Datasets — what a damaging edit propagates into.
Up one level: Changeset Analysis & Vandalism Detection.