CDN Usage Tracking for SaaS: Per-Customer Bandwidth and Request Analytics

Last year a SaaS customer of ours got a $18,000 bandwidth overage bill and immediately asked for proof. We could show them total CDN traffic, but we couldn't prove which of their end-users drove it, which assets were heaviest, or whether it was even their traffic at all. That support ticket turned into a three-week forensic mess digging through raw access logs, and it's the reason we now treat per-customer analytics as a billing requirement, not a nice-to-have. If you're selling storage, video, or any asset-heavy product through a shared CDN, you can't bill confidently or debug costs without tenant-level visibility.

Why Aggregate CDN Reports Don't Work for SaaS Billing

Most CDN dashboards are built for a single property: total requests, total bandwidth, cache hit ratio, maybe a breakdown by POP or status code. That's fine when you own the domain and pay the bill yourself. For SaaS, it's almost useless. You're sharing one distribution, one set of edge certificates, and one origin configuration across hundreds or thousands of customers, and your finance team still needs to answer "how much did customer X cost us last month?" If your only data is aggregate, you're forced to estimate — usually by dividing total cost evenly or by using application-level counters that don't match what the CDN actually served.

I've seen teams try to reconcile this with origin logs alone. It never lines up. The CDN served 40% of requests from cache, so your origin never saw them. Range requests, image transformations at the edge, and large file revalidation all change byte counts in ways your app database won't capture. And when you're doing CDN Multi-Tenant Architecture: Isolating Customer Traffic While Sharing Infrastructure correctly, customers might be sharing cache keys but still need separate accounting. According to the HTTP Working Group Specifications, a 304 Not Modified or a 206 Partial Content response has very different implications for bytes transferred versus requests counted, and most naive counting scripts don't handle that correctly.

That's why we stopped treating CDN analytics as marketing charts and started treating them as metering infrastructure. Every request needs a tenant ID attached to it before it leaves the edge, otherwise you're guessing later.

# What NOT to rely on for billing - aggregate CDN stats
# This tells you nothing about per-tenant cost
{
  "timestamp": "2026-03-15T00:00:00Z",
  "totalRequests": 48291347,
  "totalBytes": 892347892344, // ~831 GiB - but for whom?
  "cacheHitRatio": 0.87,
  "statusCodes": { "200": 42103922, "304": 3188221, "206": 2999204 }
}

# What you need for SaaS metering - tenant-attributed
# Every log line carries tenant context from the edge
{
  "timestamp": "2026-03-15T00:00:01Z",
  "tenant_id": "cust_9f3a2b",
  "host": "cust9f3a.assets.yoursaas.com",
  "uri": "/videos/1080p/intro.mp4",
  "bytes_sent": 157286400,
  "cache_status": "HIT",
  "request_id": "4a3f7d2e-9b1c-4e8f"
}

Attaching Tenant Identity at the Edge

There are only three reliable places to tag a request with a customer identifier, and you need to pick one early because retrofitting is painful. The cleanest method I've used is mapping the incoming Host or path prefix to a tenant ID in an edge worker before the cache lookup happens. For white-label setups where each customer has their own domain or subdomain, you can use a lookup table at the edge. For path-based multi-tenancy like yoursaas.com/c/:customerId/*, you parse the path. Don't try to do this attribution in your origin or in a nightly ETL job by joining on URL patterns — you'll miss cached hits and you'll get burned by custom domain rewrites.

We run a lightweight edge function on every request that does two things: it validates the tenant exists, and it stamps three headers that survive into the access logs. This happens even for cache hits, which is critical. If you only stamp at the origin, your 70-90% cache hit traffic becomes unattributed. Pair this with proper CDN Tenant Isolation Patterns: Cache Separation, Rate Limits, and Access Controls so one customer's cache key can't pollute another's, and with White-Label CDN Delivery: Custom Branding for SaaS Customer Domains when you need to support vanity domains — both patterns make attribution deterministic instead of heuristic.

One mistake we made early on was using only the Host header without normalizing it. Customers pointed their CNAMEs with or without www, used uppercase letters, and occasionally sent direct IP requests that bypassed our mapping. Now we normalize and fallback to a verified custom domain table, and we drop untagged traffic into a quarantine log bucket for review. The web.dev Articles on caching and request priority were helpful here for understanding how browsers re-request assets and how that inflates request counts without inflating billable bytes.

// Cloudflare Worker / Fastly Compute@Edge / AWS CloudFront Function
// Runs on EVERY request, including cache HITs
function handleRequest(event) {
  const request = event.request;
  const host = request.headers.get('host').toLowerCase();
  const url = new URL(request.url);
  
  // 1. Resolve tenant from host or path - keep this lookup < 1ms
  // Use an edge dictionary/KV store synced from your control plane
  let tenantId = TENANT_MAP.get(host) || resolveTenantFromPath(url.pathname);
  
  if (!tenantId) {
    // Don't serve unattributed bytes as billable - quarantine it
    tenantId = "unattributed";
  }

  // 2. Stamp headers for logging and downstream analytics
  request.headers.set('x-tenant-id', tenantId);
  request.headers.set('x-tenant-bucket', getBillingTier(tenantId));
  // Vary cache by tenant to avoid cross-tenant cache poisoning
  // and to keep byte counts accurate per customer
  request.headers.set('x-cache-key-tenant', tenantId);

  return fetch(request);
}

// Log format must include these fields - example for nginx / Varnish / CDN logs
// $msec $x_tenant_id $host $request_uri $bytes_sent $request_length $upstream_cache_status $http_range

Designing Your Metering Pipeline Without Breaking the Bank

Once every request is tagged, you have to decide where and when you aggregate. This is where cost and accuracy trade off hard. Real-time streaming gives you instant per-customer dashboards and lets you trigger alerts when someone spikes 10x their normal traffic — useful if you offer burstable billing or need to catch abuse fast. But streaming every log line to Kinesis, Pub/Sub, or Kafka and running continuous aggregation isn't cheap at 50k requests per second. For most SaaS teams I've worked with, a hybrid approach works better: stream a sampled or filtered feed for alerts, and batch the full logs hourly for billing.

Don't underestimate the byte-counting nuance here. Do you bill on bytes_sent from the edge, content-length, or origin bytes plus edge overhead? For video and large downloads, clients aborting mid-stream can leave you with partial transfers that still cost you egress. We settled on billing on edge bytes_sent inclusive of headers and TLS overhead where our provider exposes it, because that's closest to what we're actually charged for. Whatever you pick, document it in your contract and stick to one definition. Customers will compare your dashboard to their own Cloudflare or Datadog numbers and they'll notice a 3-5% discrepancy if you aren't consistent about what a "request" means.

Aggregation Method Attribution Latency Billing Accuracy Cost at 1B Requests / Month Best For
Edge Real-Time Streaming (Kinesis / Pub/Sub) 5 - 30 seconds 99.9% (counts every request + bytes_sent) ~$1,200 - $2,500 + warehouse compute Abuse detection, live customer dashboards
CDN Analytics API (Polling) 5 - 15 minutes 95 - 97% (aggregated buckets, no raw bytes) ~$100 - $400 (API quotas) Directional trends, not for invoicing
Hourly Batched Log Delivery to S3/GCS + Athena/BigQuery 1 - 2 hours 99.5%+ with correct log format ~$150 - $600 (storage + query) Monthly invoicing, finance reconciliation
Origin-Only Counting Immediate 60 - 75% (misses cache hits) ~$0 extra Not recommended for CDN-heavy products

Implementing Per-Tenant Metering at the Edge: From Worker to Warehouse

Here's where most teams overthink it. You don't need a fancy metering vendor on day one — you need a Worker (or VCL snippet, or CloudFront Function) that reliably stamps every request with a tenant ID and ships it somewhere cheap. We've run this pattern at 40k+ RPS and the simplest version still wins: extract the tenant at the edge, append it as a header and a log field, and let your existing log pipeline do the heavy lifting.

If your customers are on subdomains, it's trivial — parse acme.yoursaas.com. If you're supporting vanity domains like assets.acme.com, you'll need a lookup table at the edge. Don't do a KV fetch on every single request if you can avoid it. Cache the hostname → tenant mapping in the Worker's memory or use Cloudflare's Workers KV with a 60s TTL. For teams doing Custom Domain CDN for SaaS: Automated SSL and DNS for Customer Vanity Domains, that mapping already exists for SSL automation — just reuse it. The goal is to add less than 2ms of p95 latency. If your Worker is adding 15ms, you're doing too much.

Don't stream every request individually to ClickHouse or BigQuery — you'll burn cash. At 100M requests/day, uncompressed JSON logs will run you $3,000-$5,000/month just in ingest and storage. Batch instead. Use Cloudflare Logpush to R2/S3 every 30 seconds, or buffer in the Worker and flush via Vector/Fluent Bit. We typically land logs in S3 (Parquet, ZSTD compressed), then do hourly COPY jobs into ClickHouse or Snowflake. You're looking at $80-$200/month on R2/S3 + $150 for ClickHouse Cloud for the same volume, with a 5-10 minute delay that's perfectly fine for billing. Real-time dashboards can read from Workers Analytics Engine for the last hour if you really need it.

// Cloudflare Worker: Tenant-aware metering emit
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const host = url.hostname;
    
    // 1. Resolve tenant - reuse your custom domain map
    let tenantId = request.headers.get('x-tenant-id');
    if (!tenantId) {
      // Try KV cache first (hostname -> tenantId)
      tenantId = await env.TENANT_MAP.get(host, { cacheTtl: 60 });
    }
    if (!tenantId) tenantId = 'unknown';

    // 2. Add to request for origin & cache key (optional)
    request.headers.set('x-tenant-id', tenantId);

    const response = await fetch(request);
    
    // 3. Emit minimal metering event (batched via Analytics Engine / queue)
    // Don't JSON.stringify the whole request - keep it tight
    const event = {
      ts: Date.now(),
      tenant: tenantId,
      host: host,
      path: url.pathname,
      status: response.status,
      bytes: parseInt(response.headers.get('content-length') || '0'),
      cache: response.headers.get('cf-cache-status') || 'UNKNOWN',
      colo: request.cf.colo
    };
    
    // Non-blocking write - don't await in hot path
    env.METERING_QUEUE.send(event);

    response.headers.set('x-tenant-id', tenantId);
    return response;
  }
}

Advanced Patterns and Troubleshooting: When Your Invoice Doesn't Match Your Dashboard

You'll ship this and immediately notice a 5-12% mismatch between what your CDN bills you and what you bill your customers. That's normal — and you need to reconcile it before customers complain. First, you're almost certainly counting bytes wrong. CDN bills on bytes delivered to eyeball (including headers, TLS overhead, and revalidated 304s), not origin bytes. If you use content-length, you'll undercount range requests (206s) and overcount HEAD requests. Use edgeResponseBytes from Logpush / resp.bytes_written in Varnish/Fastly. And don't forget to count HITs — we've seen teams only meter origin fetches and miss 78-92% of their actual bandwidth because cache hit ratio was that high.

At scale, you can't log 100% of requests forever. Once you're past ~500M requests/month, switch to sampled logging with deterministic correction. Log 100% of 4xx/5xx (you need it for support anyway) and 10% of 2xx/3xx, keyed by tenant. Multiply back out at query time and you'll stay within 0.8% error for bandwidth — we've validated this against full-fidelity logs for three SaaS customers doing 2-4B requests/month. Store late-arriving logs idempotently; S3 delivery can be 2-15 minutes late and occasionally duplicated. Use requestId + timestamp as a dedup key in your warehouse and run your hourly rollup job with a 90-minute watermark. If you need per-second enforcement, that's a different problem — see API Metering at CDN Edge: Real-Time Usage Tracking for SaaS Billing for counter-based approaches instead of log-based.

Two gotchas that'll bite you eventually. One: missing tenant context on preflight or bot traffic. If the Worker can't resolve a tenant, don't drop the event — log it as tenant: 'unmapped' and alert if it exceeds 0.5% of traffic. We once watched a customer leak $1,200/month in untracked bandwidth from a misconfigured DNS wildcard that bypassed the mapping. Two: identity. When enterprise customers bring their own IdP, the edge tenant lookup can get messy if you're relying on JWT sub claims passed through the CDN. Terminate auth at the edge or pass a signed header from your API gateway. Our pattern for Enterprise SSO Integration with CDN: SAML, OIDC, and Custom Identity Providers keeps the tenant resolution at the CDN layer separate from user authentication — don't conflate a user ID with a billing tenant ID.

Frequently Asked Questions

How do you handle customers on custom vanity domains vs. subdomains?

Use the same edge mapping for both. For *.yoursaas.com you can regex the tenant from the hostname with zero lookup. For assets.customer.com you need a hostname → tenantId table — the one you already maintain for SSL automation. Cache it at the edge for 60-300 seconds. We keep a 10k-entry map in Workers KV and fetch it with cacheTtl — hits cost ~0.3ms, misses about 12ms. Don't do a database lookup on every request.

Should we meter usage at the edge or at the origin?

Always at the edge. Origin only sees cache MISSes, which is typically 8-25% of total traffic if your cache hit ratio is healthy. You'll under-bill by 75%+ and your biggest customers (who benefit most from cache) will look like your smallest. Edge metering captures HITs, MISSes, revalidations, and blocked requests. Origin logs are useful for reconciliation — compare edge bytes vs. origin bytes once a day to catch drift.

How accurate does bandwidth metering need to be for billing?

For overage billing, customers will tolerate ±1-2% variance if you're transparent. For hard pay-as-you-go, aim for ±0.5%. That means counting edge bytes delivered (not origin), including 206 Partial Content and handling 304s as ~300 bytes, not zero. Sampled logging at 10% gives you about ±0.8% accuracy on bandwidth and ±1.5% on request counts at 1B+ requests/month — good enough for tiers, not for strict per-GB invoicing. If you charge $0.08/GB, a 2% error on 50TB is still an $80 dispute.

What if the tenant ID is missing or unresolvable at the edge?

Never silently drop it. Emit the event with tenantId='unknown' and increment a separate counter. If unknowns exceed 0.5% of requests or 1% of bytes for more than 10 minutes, page someone. Common causes are stale KV entries after a customer deletes a custom domain, or health checks from uptime bots hitting the raw CDN hostname. We add a nightly job that compares the edge's unmapped host list against the database and purges stale mappings — catches 99% of leaks before they hit the next invoice cycle.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker