Anonymizing IP and Geolocation in RUM

Geography is one of the most useful cohort dimensions in a RUM pipeline and the one that most often drags personal data into storage. An IP address is personal data under most privacy regimes; a country code derived from it, stored without the address, generally is not. This guide sits under Privacy-Compliant Tracking and covers resolving geography at the edge, what to do with the address afterwards, choosing a grain that is useful without being identifying, and demonstrating that the pipeline holds what you say it holds.

Prerequisites

  • An edge layer that sees the request before it reaches your collector — a CDN worker, a load balancer or an API gateway.
  • A geographic resolution source: most CDNs provide country and region headers without any lookup on your side.
  • A written retention and grain decision, reviewed by whoever owns privacy at your organisation. This guide describes engineering practice; it is not legal advice.

How to do it

Step 1 — Resolve at the edge, never in the warehouse

The address is available at the edge and should stop there. Resolve it into the coarse fields you need, attach them to the beacon, and let the address go no further.

// Edge collector: geography in, address out.
export default {
  async fetch(request, env, ctx) {
    const cf = request.cf || {};                 // CDN-provided, no lookup needed
    const geo = {
      country: typeof cf.country === 'string' ? cf.country : 'XX',
      // Region and city are progressively more identifying. Include only
      // what a dashboard genuinely uses.
      region: env.GEO_GRAIN === 'region' ? String(cf.region || '').slice(0, 32) : null,
      // A coarse network class is useful and far less identifying than an ASN.
      connection: cf.httpProtocol || null,
    };

    const body = await request.json().catch(() => null);
    const rows = buildRows(body, geo);           // the address is never passed in

    ctx.waitUntil(write(env, rows));
    return new Response(null, { status: 204 });
  },
};

The key property of this shape is that the address is never a parameter to anything downstream. It is not omitted by policy — it is structurally absent, which is a much stronger guarantee than a documented intention.

Step 2 — Decide the grain deliberately

Each level of geographic detail is more useful and more identifying, and the right answer depends on traffic volume rather than principle: a country with a hundred sessions a month is more identifying than a city with a million.

Grain Analytical value Identifiability Reasonable when
Continent Low Negligible Very small or highly regulated datasets
Country High — infrastructure and device mix Low at scale The default for most sites
Region or state Moderate — CDN pop coverage Moderate Large single-country traffic
City Low for performance work High Rarely justified
Coordinates None for performance work Very high Never in a RUM pipeline

For performance analysis specifically, country plus the network cohort from Segmenting RUM Data by Effective Connection Type answers nearly every question. City-level data adds noise and risk in roughly equal measure.

Step 3 — Suppress small cells

Even a country code becomes identifying when the cell is small enough. A k-anonymity floor applied at query time — or better, at rollup time — keeps a dashboard from displaying a row that describes a handful of people.

-- Any geography with fewer than 50 sessions in the window is folded into
-- 'other' rather than displayed.
SELECT
  multiIf(n >= 50, country, 'other')       AS geo,
  sum(n)                                   AS sessions,
  quantilesTDigestMerge(0.75)(value_state) AS p75
FROM (
  SELECT country, countMerge(samples) AS n, anyLast(value_state) AS value_state
  FROM rum_daily
  WHERE metric = 'LCP' AND day >= today() - 28
  GROUP BY country
)
GROUP BY geo
ORDER BY sessions DESC;

Fifty is a common floor and the number matters less than having one. Below any such threshold the percentile is statistically meaningless anyway, as the intervals in Calculating Confidence Intervals for a Sampled p75 show — so suppression costs you nothing you could have used.

Step 4 — If you must keep an address-derived value, truncate and salt

Some pipelines need a coarse network identifier for abuse detection or rate limiting. Keep a keyed hash of a truncated address rather than the address, with a salt that rotates.

// A rotating-salt hash of a truncated address. Not reversible, not stable
// across days, adequate for per-window rate limiting.
async function addressKey(ip, env) {
  const truncated = ip.includes(':')
    ? ip.split(':').slice(0, 3).join(':')      // IPv6: keep the /48
    : ip.split('.').slice(0, 3).join('.');     // IPv4: keep the /24
  const salt = await env.SALTS.get('daily');   // rotated by a scheduled job
  const data = new TextEncoder().encode(`${salt}:${truncated}`);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return [...new Uint8Array(digest)].slice(0, 8)
    .map((b) => b.toString(16).padStart(2, '0')).join('');
}

Three properties make this defensible: the input is already truncated so it identifies a network rather than a household, the salt makes it non-reversible without the key, and rotation means the value cannot link sessions across days. Store it with a short TTL and never in the analytics table.

What survives each stageEach stage discards something it cannot put back. By the time a row reaches storage, the address is not redacted — it was never there.What survives each stageThe address exists only inside the first boxEdge requestfull address availableResolvecountry, coarse regionTruncate and hashrate-limit key onlyStored rowcountry + cohorts
Each stage discards something it cannot put back. By the time a row reaches storage, the address is not redacted — it was never there.

Step 5 — Document and demonstrate what you hold

A privacy claim that cannot be checked is a promise. Two artefacts make it verifiable.

A field inventory, generated from the schema rather than written by hand, listing every stored column, its purpose and its retention. If a column is not on the list, the writer should reject it — which the validation layer in Rate Limiting and Validating RUM Beacons already enforces.

A periodic sample read. Pull a hundred rows at random and read them. Automated checks catch known patterns; a human reading real rows catches the unexpected — a route that still contains an email, a referrer with a token in the query string, a custom metric name carrying a user id.

// A cheap scanner for the patterns that should never appear in a stored row.
const FORBIDDEN = [
  /[\w.+-]+@[\w-]+\.[\w.]+/,                     // email
  /\b\d{1,3}(\.\d{1,3}){3}\b/,                   // IPv4
  /\b(?:token|session|auth|key|password)=/i,     // credentials in a query string
  /\b\d{13,19}\b/,                               // long numeric ids
];

export function scanRow(row) {
  const text = JSON.stringify(row);
  return FORBIDDEN.filter((re) => re.test(text)).map((re) => re.source);
}

Run it over a sample nightly and alert on any hit. It is a few lines of code and it catches the class of leak that reaches production through a well-intentioned new field.

Sessions per country in a 28-day windowBelow the floor the percentile is meaningless and the row describes a handful of people. Suppression costs nothing analytically and removes the risk entirely.Sessions per country in a 28-day windowWhere the cell-size floor starts to bind on a mid-sized siteLargest market412,000Second market86,000Fifth market9,400Tenth market1,200Twentieth market380125,000250,000375,000500,000reporting floor
Below the floor the percentile is meaningless and the row describes a handful of people. Suppression costs nothing analytically and removes the risk entirely.

What geography is actually good for

Worth stating, because the grain debate is easier when everyone knows what the dimension is for.

Infrastructure coverage. A country with high TTFB and normal downstream metrics is a CDN presence problem, and it is the clearest signal geography provides. The split is visible with the cache-status dimension from Segmenting TTFB by CDN Cache Status.

Device and network mix. Countries differ enormously in device capability and network quality. A country that looks slow may have excellent infrastructure and predominantly low-tier devices, which is a completely different work queue.

Regulatory scoping. Knowing which traffic is subject to which consent regime is itself a reason to keep country, and it is the one use that survives every privacy review.

What geography is not good for is targeting individuals, which is precisely why the coarse grain costs you nothing analytically.

Verifying it works

  1. No address appears in any stored row. Grep a sample; the scanner above automates it.
  2. Small cells are suppressed in every dashboard, not just the main one. A single unsuppressed panel undoes the policy.
  3. The salt actually rotates. A rotation job that silently failed leaves a stable identifier in place for months.
  4. The field inventory matches the schema. Generate it from the database rather than maintaining it separately, or it will drift.
  5. Deleting a session is possible if you promised it. If you hold no session identifier, the answer is that there is nothing to delete — which is a stronger position, and should be documented as such.
What each field costs and buysThe field with the most exposure has no analytical value at all for performance work — which is what makes this an easy design decision rather than a trade-off.What each field costs and buysAnalytical value against the privacy exposure it createsAnalytical valueExposureKeep it?CountryHighLow at scaleYesRegionModerateModerateSometimesCityLowHighRarelyFull IP addressNoneVery highNever
The field with the most exposure has no analytical value at all for performance work — which is what makes this an easy design decision rather than a trade-off.

Edge cases and gotchas

  • Referrer and page URL leak more than the address. A query string with a token in it is a far more common leak than an IP field, and it is invisible unless you look.
  • Geography from a VPN or corporate egress is wrong. A share of traffic will be attributed to the wrong country. It is noise, not a bug, and it is another reason not to over-refine the grain.
  • XX and unknown countries accumulate. Watch the share; a sudden rise usually means an edge configuration change rather than a change in traffic.
  • Region names are not stable. Providers rename and re-code administrative regions between versions, which silently splits a series.
  • Hashing without truncation is not anonymisation. A hash of a full address is still a unique per-address identifier, and is treated as personal data in most regimes.
  • Logs are part of the pipeline. An edge log line containing the address and the beacon payload undoes the entire design; check what your platform retains by default.

FAQ

Is an IP address personal data?

Under GDPR and several similar regimes, yes — including dynamic addresses. That is why the practice here is to resolve it at the edge and never store it, rather than to store it and restrict access.

Is a country code personal data?

Generally not on its own at scale, because it does not single anyone out. Combined with enough other fields on a small population it can contribute to identifiability, which is what the cell-size floor addresses.

Can I keep city-level data for performance work?

You can, and for performance analysis it rarely earns its risk. Country plus network cohort answers the infrastructure question; city mostly adds cells below the sample floor.

What about legitimate abuse prevention?

Use a truncated, salted, rotating hash with a short TTL, kept outside the analytics store. That supports rate limiting without creating a durable identifier.