ServicesGeo

Geo

A spatial index for "where is everything right now": you push entity positions into an index, then ask radius, bounding-box, and distance-between questions over the current snapshot. Geofences - circle and polygon shapes with an on-demand containment check - live on the same index.

When to reach for it: fleet and courier tracking, "show me drivers within 2 km", delivery and service-area checks, proximity matching, any feature that answers a question about current positions.

When not to: whether an entity is still alive and reporting belongs to Liveness; a durable history of where something has been belongs to Timeline - geo keeps only the latest position per entity and overwrites the previous one.

Concepts

  • The index is the unit of ownership. Entities, fences, and queries hang off one index id, and only its creator can touch it - everyone else, platform admin tokens included, gets 403 unauthorized. There is no sharing mechanism, and the owner reads every position in the index, so an index of end users' locations belongs behind your own backend rather than in front of per-user tokens.
  • Positions are upserts under an id you choose. update-location creates or replaces; there is no separate create call.
  • Longitude comes first, and storage is lossy. Coordinates are stored as a geohash, so what you read back differs from what you wrote by well under a metre.
  • unit governs the whole query, input and output. Despite the names, radius_meters is read in the unit you asked for and distance_meters is reported in it.
  • Deactivating an index locks it. With active: false every operation except listing and PATCH returns 400 index_inactive.

Create an index

snug geo create --name fleet --description "Fleet tracking index"
snug geo create --name fleet \
  --max-radius-meters 50000 --default-limit 100 --max-limit 1000 \
  --max-entities 100000 --entity-ttl-seconds 86400

snug --output json geo list --limit 5
snug geo update --id <index-id> --max-radius-meters 25000
snug geo delete-index --id <index-id> --force

Those five knobs are the only per-index tuning and all are optional. max_radius_meters (50000) and max_limit (1000) bound queries, default_limit (100) applies when a query omits limit, and max_entities / entity_ttl_seconds are unset - unlimited and forever. delete-index drops the index and all its positions, but not its geofences, which survive as orphans; delete those first. list takes the shared search grammar, scoped to indexes you own.

Record positions

snug geo update-location --id <index-id> -e driver1 \
  -x -122.4194 -y 37.7749 -m '{"status":"available","vehicle":"van"}'

snug geo batch-update-location --id <index-id> --file positions.json
snug geo get-entity --id <index-id> -e driver1
snug geo list-entities --id <index-id> --limit 100
snug geo delete-entity --id <index-id> -e driver1

The CLI's --file takes a bare JSON array of {entity_id, longitude, latitude, metadata?}; the HTTP endpoint wraps the same array in {"entities": [...]}. A batch over 1000 entries is rejected by request validation, and one over the index's max_limit with 400 batch_too_large.

An upsert replaces the whole record, so omitting metadata on a later write clears what was there. Resend it on every position update, or keep it somewhere movement does not overwrite:

snug geo update-location --id <index-id> -e driver1 -x -122.42 -y 37.775
snug --output json geo get-entity --id <index-id> -e driver1
# -> "metadata": {}

Query

snug geo query-radius --id <index-id> -x -122.4194 -y 37.7749 -r 2000 -d -c
snug geo query-bbox --id <index-id> \
  --min-lon -122.45 --min-lat 37.70 --max-lon -122.35 --max-lat 37.80 -c
snug geo distance --id <index-id> --from driver1 --to driver4 -u km
snug geo stats --id <index-id>

Radius results come back nearest-first:

curl -s -H "Authorization: Bearer $SNUG_API_TOKEN" \
  "http://localhost:4000/api/v1/geo/$INDEX/query/radius?longitude=-122.4194&latitude=37.7749&radius_meters=2000&limit=10"
{
  "status": 200,
  "msg": "OK",
  "data": {
    "entities": [
      {
        "entity_id": "docs4-driver1",
        "longitude": -122.41940170526505,
        "latitude": 37.77490001056578,
        "distance_meters": 0.1499,
        "metadata": { "status": "available", "vehicle": "van" },
        "last_updated": 1788007921
      },
      {
        "entity_id": "docs4-driver2",
        "longitude": -122.40889817476273,
        "latitude": 37.78370056243101,
        "distance_meters": 1345.5461,
        "metadata": { "status": "busy" },
        "last_updated": 1788007622
      }
    ],
    "query_center": { "longitude": -122.4194, "latitude": 37.7749 },
    "query_bounds": null,
    "total_found": 2,
    "timestamp": 1788007921
  }
}

query_center is populated for radius queries and query_bounds for bounding-box queries; the other is null. Note driver1: written at exactly -122.4194, 37.7749, it reports 0.1499 metres from a query centred on itself. That drift is the geohash resolution - never test a position you just wrote for exact equality. distance between two stored entities is the one call that names its unit honestly, returning {"distance": 13.4419, "unit": "km"}.

Behaviors and gotchas

  • The CLI hides coordinates unless you ask. Without -c/-d, query-radius and query-bbox send with_coordinates=false and with_distance=false, and the fields come back as longitude: 0.0, latitude: 0.0, distance_meters: null - zero being a real coordinate, this reads as the Gulf of Guinea, not as missing data. The HTTP API defaults both to true; only the CLI opts out. Pass -d -c whenever you want positions back.
  • limit is clamped, radius_meters is not. limit=5000 against an index whose max_limit is 1000 quietly returns at most 1000 rows, but a radius over the index cap is 400 invalid_radius.
  • Timestamps use two scales. Entity last_updated and the query timestamp are epoch seconds; index created_at/updated_at and the stats last_update are epoch milliseconds.
  • stats never computes bounds - always null - and last_update is the index manifest's timestamp, which entity writes do not move. Only its entity_count is counted live.
  • TTL and caps are enforced on write. With entity_ttl_seconds: 3 an entity was gone from list-entities and 404 entity_not_found five seconds later; with max_entities: 2, the third entity failed with 400 max_entities_reached.
  • A deactivated index is not readable. After PATCH {"active": false}, even GET /api/v1/geo/{id} returns 400 index_inactive. Only list (which still shows it) and another PATCH work, so PATCH {"active": true} is the way back.
  • Bad geometry is rejected in the envelope: latitude outside [-90, 90] or longitude outside [-180, 180] is 400 invalid_coordinates, an unknown unit is 400 invalid_unit, and a box whose min is not strictly below its max is 400 invalid_bounding_box.

Geofences

Circle and polygon shapes and the containment check have their own page: Geofences.

Limits and configuration

Every limit is per-index, set through config at create or update time: the Geo CONFIG reference records that the service reads no GEO_* environment variables at all.

Reference

  • Geo API - every endpoint, callable
  • Related: Liveness for whether a tracked entity is still reporting, Timeline for position history, KV Store for their non-spatial attributes

On this page