Vector IP Enrichment with IPGeolocation.io: Geolocation, VPN Detection and ASN Data


Overview

Vector can look up any IP address in an IPGeolocation.io database while events are in flight. You declare each MMDB file as an enrichment table, call it from a remap transform, and get the complete record for that address back as a VRL object. VRL then decides what to keep, what to name it and which type it gets, before the event reaches Elasticsearch, ClickHouse, Datadog, Amazon S3 or any other sink.

The examples in this guide combine three databases: the IP Geolocation Database for country, region, city, coordinates and time zone, the IP Security Database for VPN, proxy and Tor flags and a threat score, and the IP to ASN Database for the autonomous system number (ASN) and its owner.

Every other IPGeolocation.io IP database works the same way: the IP to Country, IP to City and IP to ASN databases, the IP to Company Database, IP Abuse Contact Database, IP WHOIS Database, IP to Hosting Database and Residential Proxy Database, plus combined databases that pack several of them into one file.

The files sit next to Vector, so enrichment adds no network round trip and no per-event cost, and the IP addresses in your events stay inside your infrastructure. For help choosing, see when to use an IP database instead of an API.


At a glance

TopicDetails
Vector componentsOne mmdb enrichment table per database file, and a remap transform
What a lookup returnsThe whole database record for the address, as a VRL object
Where lookups runIn memory on the Vector host, with no API calls
What leaves VectorClean, typed fields, such as geo , security and network objects
Tested withVector 0.58.0

Why Vector suits IPGeolocation.io data

  • One lookup, the whole record. Each call returns every field for the address. Copy one value, a group of values, or the entire record.
  • Real types before storage. VRL turns the "true" and "false" flags into booleans and the coordinate strings into floats, so your sink stores typed values.
  • Lists stay lists. VPN and proxy provider names arrive as arrays, ready for array fields in your backend.
  • Failed lookups cannot slip through. VRL refuses to load a lookup whose error is not handled, so an unknown address never breaks the pipeline unnoticed.
  • Tests live with the config. vector test checks your enrichment logic before you deploy it.
  • Updates without a restart. A SIGHUP makes Vector reload the database files.

Where it helps

  • Maps in Elasticsearch and OpenSearch. Build a geo.location object with numeric lat and lon , ready for a geo_point field.
  • A separate stream for risky traffic. Route events from VPNs, proxies and Tor to your SIEM, and send everything else to cheaper storage.
  • Typed columns in ClickHouse or BigQuery. Store flags as booleans and AS numbers as integers, with no casting at query time.
  • Abuse handling. Attach the network owner, or the abuse contact from the IP Abuse Contact Database, to events that hit your rate limits.

What is Vector?

Vector is an open source tool for building observability pipelines, written in Rust. It collects logs and metrics from sources such as files, Kubernetes, syslog and Kafka, reshapes them with VRL (Vector Remap Language), and delivers them to dozens of sinks.

An enrichment table lets a VRL program look up reference data, such as an IPGeolocation.io database, for every event. That is how IP enrichment for logs happens inside Vector, before the data is stored.


How the lookup works

  1. At startup, Vector loads each mmdb enrichment table into memory.
  2. In a remap transform, get_enrichment_table_record looks up the event's IP address.
  3. The call returns the record for the address, or an error when the address is not in the file or is not a valid IP.
  4. VRL copies and converts the fields you want onto the event, and the event moves on to its sinks.

1. The record Vector returns

This is the complete IP Security Database record for 37.120.202.92 , a VPN exit, as VRL receives it:

Response Preview
1{
2  "bot_confidence_score": 0,
3  "bot_last_seen": "",
4  "bot_operator_name": "",
5  "bot_type": "",
6  "cloud_provider_name": "M247",
7  "corporate_gateway_provider_name": "",
8  "corporate_gateway_type": "",
9  "is_anonymous": "true",
10  "is_bot": "false",
11  "is_cloud_provider": "true",
12  "is_corporate_gateway": "false",
13  "is_known_attacker": "false",
14  "is_known_good_bot": "false",
15  "is_proxy": "true",
16  "is_relay": "false",
17  "is_residential_proxy": "false",
18  "is_spam": "false",
19  "is_tor": "false",
20  "is_vpn": "true",
21  "proxy_confidence_score": 99,
22  "proxy_last_seen": "2026-09-08",
23  "proxy_provider_names": [],
24  "relay_provider_name": "",
25  "threat_score": 50,
26  "vpn_confidence_score": 99,
27  "vpn_last_seen": "2026-09-28",
28  "vpn_provider_names": [
29    "Private Internet Access VPN"
30  ]
31}

Flags are strings, scores are integers, and provider names are lists. In VRL, you reach a value with a field path on the returned object. If the record is stored in sec , the VPN flag is sec.is_vpn . Location records nest deeper: the country code in the IP Geolocation Database is loc.location.country.code2 .


Requirements

ComponentDetails
Vector0.58.0 (tested), with the mmdb enrichment table type. Check with vector list .
IPGeolocation.io databasesOne or more IP databases in MMDB format, downloaded from your IPGeolocation.io account. To pick a database, see IP database pricing.
Your eventsA field that holds the client IP address. The quick start uses remote_addr .

Quick start

Pipe one JSON event through Vector in Docker and watch it come out enriched. No log files or sinks are needed.


1. Step 1: Collect the database files

From your account, download the three MMDB files into a folder named databases :

Response Preview
1databases/
2├── db-ip-asn.mmdb
3├── db-ip-location.mmdb
4└── db-ip-security.mmdb

2. Step 2: Write the pipeline

Save this as vector.yaml next to the databases folder:

Response Preview
1enrichment_tables:
2  ipgeo_location:
3    type: mmdb
4    path: /usr/local/share/ipgeolocation/db-ip-location.mmdb
5  ipgeo_security:
6    type: mmdb
7    path: /usr/local/share/ipgeolocation/db-ip-security.mmdb
8  ipgeo_asn:
9    type: mmdb
10    path: /usr/local/share/ipgeolocation/db-ip-asn.mmdb
11
12sources:
13  app:
14    type: stdin
15    decoding:
16      codec: json
17
18transforms:
19  ipgeo:
20    type: remap
21    inputs: [app]
22    source: |
23      loc, err = get_enrichment_table_record("ipgeo_location", {"ip": .remote_addr})
24      if err == null {
25        .geo.country_code = loc.location.country.code2
26        .geo.country_name = loc.location.country.name.en
27        .geo.region = loc.location.state.name.en
28        .geo.city = loc.location.city.name.en
29        .geo.time_zone = loc.time_zone
30        .geo.location.lat = to_float(loc.location.coordinates.latitude) ?? null
31        .geo.location.lon = to_float(loc.location.coordinates.longitude) ?? null
32      }
33
34      sec, err = get_enrichment_table_record("ipgeo_security", {"ip": .remote_addr})
35      if err == null {
36        .security.threat_score = sec.threat_score
37        .security.is_vpn = sec.is_vpn == "true"
38        .security.is_proxy = sec.is_proxy == "true"
39        .security.is_tor = sec.is_tor == "true"
40        .security.is_anonymous = sec.is_anonymous == "true"
41        .security.vpn_providers = sec.vpn_provider_names
42      }
43
44      net, err = get_enrichment_table_record("ipgeo_asn", {"ip": .remote_addr})
45      if err == null {
46        .network.asn = to_int(net.asn.as_number) ?? null
47        .network.organization = net.asn.organization
48      }
49
50sinks:
51  out:
52    type: console
53    inputs: [ipgeo]
54    encoding:
55      codec: json

3. Step 3: Send an event through it

echo '{"remote_addr":"37.120.202.92","path":"/login"}' | docker run --rm -i \
  -v "$PWD/databases":/usr/local/share/ipgeolocation:ro \
  -v "$PWD/vector.yaml":/etc/vector/vector.yaml:ro \
  timberio/vector:0.58.0-alpine --config /etc/vector/vector.yaml

4. Step 4: Read the result

Vector prints the event as one JSON line. Formatted, and without the host , source_type and timestamp fields that Vector adds, it looks like this:

Response Preview
1{
2  "geo": {
3    "city": "Secaucus",
4    "country_code": "US",
5    "country_name": "United States",
6    "location": {
7      "lat": 40.78834,
8      "lon": -74.05502
9    },
10    "region": "New Jersey",
11    "time_zone": "America/New_York"
12  },
13  "network": {
14    "asn": 9009,
15    "organization": "M247 Europe SRL"
16  },
17  "path": "/login",
18  "remote_addr": "37.120.202.92",
19  "security": {
20    "is_anonymous": true,
21    "is_proxy": true,
22    "is_tor": false,
23    "is_vpn": true,
24    "threat_score": 50,
25    "vpn_providers": [
26      "Private Internet Access VPN"
27    ]
28  }
29}

Compare it with the raw record above: the flags are now booleans, the coordinates are numbers in a geo_point -style object, and the AS number is an integer. Values depend on your database release. The address belongs to AS9009; its routes, peers and WHOIS data are in the IPGeolocation.io ASN browser entry for AS9009.

To go to production, replace the stdin source with your real source, such as file , kubernetes_logs or kafka , and the console sink with your real sink.


Configuration reference


1. Enrichment table settings

SettingValue
type mmdb .
path Path to the MMDB file. In Docker, the path inside the container.

The table name, such as ipgeo_security , is the name you pass to get_enrichment_table_record .


2. The lookup call

record, err = get_enrichment_table_record("<table name>", {"ip": <field with the IP>})
ResultWhen
record is the database record, err is null The address is in the file.
err contains No rows found The address is not in the file, for example a private address.
err contains Invalid address: invalid IP address syntax The field is missing, empty, or not a single IP address.

Always use the two-value form and check err , as in the quick start. Vector rejects a configuration that ignores the error.


3. Field paths in each database

Every IPGeolocation.io IP database can be an enrichment table. With the record stored in rec , these are typical paths:

DatabaseExample VRL path
IP Geolocation Database and IP to City Database rec.location.country.code2 , rec.location.city.name.en , rec.time_zone
IP to Country Database rec.location.country.code2
IP Security Database rec.is_vpn , rec.threat_score , rec.vpn_provider_names
IP to ASN Database rec.asn.as_number , rec.asn.organization
IP to Company Database rec.company.name.en , rec.company.domain
IP to ISP Database rec.isp , rec.asn , rec.as_organization , rec.country.code2
IP Abuse Contact Database rec.abuse.emails , rec.abuse.name.en
IP WHOIS Database rec.whois.organization.name , rec.whois.rir
IP to Hosting Database rec.hosting_provider
Residential Proxy Database rec.proxy_provider , rec.last_seen

In the IP to ISP Database, rec.asn is the AS number itself rather than a group, and the country sits at the top level instead of under location . The two datasets answer different questions; see how an ISP differs from an ASN.

Names come in several languages. Replace en with cs , de , es , fa , fr , it , ja , ko , pt , ru or zh . A name with no translation is an empty string.

For the meaning of each security field, see the IP Security Database documentation.


4. Keeping the whole record

To keep every field instead of picking some, assign the record. This copies the full security record and turns every "true" and "false" into a boolean in one step, while scores, dates and lists stay as they are:

sec, err = get_enrichment_table_record("ipgeo_security", {"ip": .remote_addr})
if err == null {
  .security = map_values(sec) -> |value| {
    if value == "true" { true } else if value == "false" { false } else { value }
  }
}

5. Reading a combined database

A combined database holds the data of several databases, such as location, company and ASN, in a single file. Declare it once, and one lookup returns all of it:

Response Preview
1enrichment_tables:
2  ipgeo_city_company_asn:
3    type: mmdb
4    path: /usr/local/share/ipgeolocation/db-ip-city-company-asn.mmdb
rec, err = get_enrichment_table_record("ipgeo_city_company_asn", {"ip": .remote_addr})
if err == null {
  .geo.country_code = rec.location.country.code2
  .geo.city = rec.location.city.name.en
  .network.asn = to_int(rec.asn.as_number) ?? null
  .network.company = rec.company.name.en
}

Recipes


1. Send anonymous traffic to its own sink

Add a route transform after the enrichment. Events from VPNs, proxies and Tor go to one sink, and everything else to another:

Response Preview
1transforms:
2  # ... the ipgeo transform from the quick start ...
3
4  split:
5    type: route
6    inputs: [ipgeo]
7    route:
8      anonymous: .security.is_anonymous == true
9
10sinks:
11  siem:
12    type: console            # replace with your SIEM sink
13    inputs: [split.anonymous]
14    encoding:
15      codec: json
16  everything_else:
17    type: console            # replace with your normal sink
18    inputs: [split._unmatched]
19    encoding:
20      codec: json

Because the quick start already turned the flags into booleans, the condition compares with true , not "true" . To route on a single signal, use .security.is_tor == true or .security.is_vpn == true .


2. Enrich an Nginx access log

VRL parses the standard Nginx log format itself, so no regular expression is needed. parse_nginx_log puts the client address in .client . Keep the enrichment_tables section from the quick start, and change the source and transform:

Response Preview
1sources:
2  nginx:
3    type: file
4    include: [/var/log/nginx/access.log]
5
6transforms:
7  ipgeo:
8    type: remap
9    inputs: [nginx]
10    source: |
11      . = parse_nginx_log!(.message, "combined")
12      loc, err = get_enrichment_table_record("ipgeo_location", {"ip": .client})
13      if err == null {
14        .geo = {
15          "country_code": loc.location.country.code2,
16          "city": loc.location.city.name.en
17        }
18      }

After parse_nginx_log , VRL knows the exact shape of the event, so assign .geo as a whole object rather than one field at a time.

For the line 37.120.202.92 - - [06/Oct/2026:10:15:32 +0000] "POST /login HTTP/1.1" 401 512 "-" "Mozilla/5.0" , the event gets client , request , status ( 401 ) and size ( 512 ) from the parser, plus geo.country_code ( "US" ) and geo.city ( "Secaucus" ).


3. Use the visitor's address from X-Forwarded-For

Behind a load balancer or CDN, the connecting address belongs to the proxy. If your events carry an X-Forwarded-For value, take its first entry before the lookup:

if exists(.x_forwarded_for) {
  first = split(string(.x_forwarded_for) ?? "", ",")[0]
  .remote_addr = strip_whitespace(string(first) ?? "")
}

For 37.120.202.92, 10.0.0.1 , this sets .remote_addr to 37.120.202.92 . Only trust this header when your own proxy sets it; see how to get the real client IP address behind a proxy.


Testing the enrichment with vector test

Vector can unit-test a transform against the real database files. Add a tests section to the configuration from the route recipe:

Response Preview
1tests:
2  - name: VPN exit is flagged and routed
3    inputs:
4      - insert_at: ipgeo
5        type: log
6        log_fields:
7          remote_addr: 37.120.202.92
8    outputs:
9      - extract_from: split.anonymous
10        conditions:
11          - type: vrl
12            source: |
13              assert_eq!(.security.is_vpn, true)
14              assert!(to_int!(.security.threat_score) >= 50)

Then run:

vector test /etc/vector/vector.yaml
Response Preview
1Running tests
2test VPN exit is flagged and routed ... passed

A failed assertion prints the expected and actual values and exits with a non-zero code, so the test can gate a deployment in CI. Test conditions do not know field types, so convert numbers with to_int! before comparing them.


Keeping the databases up to date

IPGeolocation.io publishes new databases daily or weekly, depending on your plan. Vector keeps each table in memory, so a new file takes effect when Vector reloads. Downloads come as ZIP archives that contain the MMDB files of your plan together with a checksum.txt of SHA-256 hashes, so an update has three parts:

  1. Unpack the archive next to the live files, and confirm both the hashes and the databases themselves.
  2. Rename each new MMDB file over the old one. The rename is atomic.
  3. Send Vector a SIGHUP : kill -HUP <pid> , or docker kill --signal HUP <container> in Docker. Vector reloads the configuration and the enrichment tables without stopping.

This script covers all three. Put the MMDB download link from your IPGeolocation.io account in DOWNLOAD_URL . It relies on curl , unzip , sha256sum and the mmdbio command-line tool, and a release that fails any check never replaces a good file:

#!/bin/sh
# Install a new IPGeolocation.io database release and reload Vector.
set -eu

DB_DIR=/usr/local/share/ipgeolocation
DOWNLOAD_URL="<MMDB download link from your IPGeolocation.io account>"

# 1. Unpack the release in a temporary folder beside the live databases.
WORK=$(mktemp -d "$DB_DIR/.release.XXXXXX")
trap 'rm -rf "$WORK"' EXIT
curl -fsSL -o "$WORK/release.zip" "$DOWNLOAD_URL"
# -DD stamps the unpacked files with the current time, not the archive's.
unzip -q -DD "$WORK/release.zip" -d "$WORK"
rm "$WORK/release.zip"

# 2. Stop on a bad hash or a damaged database.
(cd "$WORK" && sha256sum --quiet -c checksum.txt)
for db in "$WORK"/*.mmdb; do
    mmdbio verify --db "$db"
done

# 3. Swap each database in with an atomic rename.
for db in "$WORK"/*.mmdb; do
    mv "$db" "$DB_DIR/"
done

# 4. Reload Vector.
kill -HUP "$(pidof vector)"

Because every release uses the same file names, the path of each enrichment table stays valid. The -DD option matters here: on a reload, Vector rereads a table only when its file is newer than the copy it loaded, and a release unpacked with the archive's original timestamps can look older than the file it replaces. Schedule the script with cron after each release. In Docker, mount the directory that holds the databases, as in the quick start, so the container sees the renamed files.


Production notes

  • Memory. Vector keeps each database file in memory. Budget RAM for at least the combined size of the files you load.
  • Only what you need. Load only the databases your pipeline reads, and copy only the fields your dashboards and alerts use.
  • The right address. Look up the visitor's address, not your proxy's. See the X-Forwarded-For recipe above.
  • Kubernetes. Mount the databases from a volume at the same path in every Vector pod. After an update, send SIGHUP to each pod or restart the workload.
  • Tests in CI. Run vector test on every configuration change.

Troubleshooting

Events have no geo , security or network fields. The lookup returned an error and the if err == null block was skipped. Store the error on the event to see it, for example .lookup_error = err in an else branch:

•
No rows found : the address is not in that database. Check it with mmdbio:
mmdbio read --db /usr/local/share/ipgeolocation/db-ip-location.mmdb --ip 37.120.202.92
•
Invalid address: invalid IP address syntax : the field is missing, empty, a list such as 203.0.113.7, 10.0.0.1 , or an address with a port.

Unsupported MMDB database type (ipgeolocation.io Database). Use mmdb enrichment table instead. The table is declared as type: geoip . Change it to type: mmdb .

error[E103]: unhandled fallible assignment . The lookup uses the one-value form, rec = get_enrichment_table_record(...) . Use rec, err = ... and check err .

i/o error: No such file or directory . The path of an enrichment table does not exist. In Docker, use the path inside the container.

error[E630]: fallible argument in a test. A test condition compares a value whose type is unknown. Convert it first, as in to_int!(.security.threat_score) >= 50 .

New data does not appear after an update. Vector still holds the old file in memory. Send it a SIGHUP .


FAQ

mmdb . It returns the full record and works with every IPGeolocation.io IP database. The geoip type rejects these files.
Yes. Declare one enrichment table per file and call each one from the same remap transform, as the quick start does with three databases.
No. Vector reads the files on its own host. No API key or network access is needed at runtime.
Yes. Each database covers both IPv4 and IPv6 addresses, and the lookup accepts either.
Yes. Compare each flag with "true" , as in the quick start, or convert all of them at once with map_values , as shown under "Keeping the whole record".
Rename the new file over the old one and send Vector a SIGHUP . Vector reloads the tables without stopping.

Related

Subscribe to Our Newsletter

Get the latest in geolocation tech, straight to your inbox.