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
| Topic | Details |
|---|---|
| Vector components | One mmdb enrichment table per database file, and a remap transform |
| What a lookup returns | The whole database record for the address, as a VRL object |
| Where lookups run | In memory on the Vector host, with no API calls |
| What leaves Vector | Clean, typed fields, such as geo , security and network objects |
| Tested with | Vector 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 testchecks your enrichment logic before you deploy it. - Updates without a restart. A
SIGHUPmakes Vector reload the database files.
Where it helps
- Maps in Elasticsearch and OpenSearch. Build a
geo.locationobject with numericlatandlon, ready for ageo_pointfield. - 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
- At startup, Vector loads each
mmdbenrichment table into memory. - In a
remaptransform,get_enrichment_table_recordlooks up the event's IP address. - 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.
- 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:
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
| Component | Details |
|---|---|
| Vector | 0.58.0 (tested), with the mmdb enrichment table type. Check with vector list . |
| IPGeolocation.io databases | One or more IP databases in MMDB format, downloaded from your IPGeolocation.io account. To pick a database, see IP database pricing. |
| Your events | A 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 :
1databases/
2├── db-ip-asn.mmdb
3├── db-ip-location.mmdb
4└── db-ip-security.mmdb2. Step 2: Write the pipeline
Save this as vector.yaml next to the databases folder:
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: json3. 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.yaml4. 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:
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
| Setting | Value |
|---|---|
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>})| Result | When |
|---|---|
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:
| Database | Example 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:
1enrichment_tables:
2 ipgeo_city_company_asn:
3 type: mmdb
4 path: /usr/local/share/ipgeolocation/db-ip-city-company-asn.mmdbrec, 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:
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: jsonBecause 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:
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:
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.yaml1Running tests
2test VPN exit is flagged and routed ... passedA 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:
- Unpack the archive next to the live files, and confirm both the hashes and the databases themselves.
- Rename each new MMDB file over the old one. The rename is atomic.
- Send Vector a
SIGHUP:kill -HUP <pid>, ordocker 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
SIGHUPto each pod or restart the workload. - Tests in CI. Run
vector teston 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.92Invalid 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.remap transform, as the quick start does with three databases."true" , as in the quick start, or convert all of them at once with map_values , as shown under "Keeping the whole record".SIGHUP . Vector reloads the tables without stopping.Related
- IPGeolocation.io MMDB field reference
- IP Geolocation Database documentation
- IP Security Database documentation
- IP to ASN Database documentation
- IP to Company Database documentation
- Enrich logs with IPGeolocation.io in Fluent Bit
- Enrich logs for Loki with IPGeolocation.io in Grafana Alloy
- IPGeolocation.io Nginx module for MMDB databases
- All IPGeolocation.io integrations
- VRL function reference