Attributing Tile Serving Cost to Consumers
Cost data cannot be enriched retroactively. A tile response that was served without recording which consumer asked for it is permanently unattributable, and no amount of later analysis recovers it — which is why attribution is a plumbing decision made before the first request rather than a reporting exercise afterwards. This guide wires caller identity from the zero-trust layer through to the cost record, computes each consumer’s share of a product’s serving cost, and shows what to do with the answer. It implements the attribution requirement in Cost Observability for Spatial Workloads within Spatial Pipeline Orchestration & Observability.
Prerequisites
| Requirement | Value / Assumption | Notes |
|---|---|---|
| Tools | Prometheus ≥ 2.45, the tile service, an edge exporter | Cost is a metric, not a report |
| Identity | mTLS workload identity resolved at the edge | An unattributed request is unattributable forever |
| Rate card | Published unit costs, versioned | A ratio across a pricing change is not comparable |
| Cardinality budget | Bounded caller set — roles, not end users | Per-end-user labels will exhaust Prometheus |
| Access roles | platform-engineer (emitters), domain-owner (reads own products) |
One domain must not browse another’s consumers |
| Environment | PROM, PRODUCT |
Exported before running |
The cardinality row is the constraint that shapes everything. Caller identity must be a bounded set — service identities, team roles — not end users, or the label combination count grows without limit and the metric becomes the most expensive thing in the estate.
Step-by-Step Implementation
1. Carry the caller identity from the edge to the emitter
The identity exists at the edge, where mTLS resolved it. It has to survive to the point where cost is recorded, which usually means an explicit header rather than an assumption.
# request_context.py — the identity travels with the request, deliberately.
from dataclasses import dataclass
@dataclass(frozen=True)
class ServeContext:
domain: str
product: str
version: str
port: str
caller: str # the workload identity the edge authenticated
from_cache: bool
def context_from_request(req) -> ServeContext:
"""The caller comes from the identity header the edge sets after mTLS. It is
never read from a client-supplied value: a caller who can name themselves can
also name somebody else, which makes attribution worse than having none."""
caller = req.headers.get("x-authenticated-identity")
if not caller:
# Fail loudly rather than recording "unknown": an unattributed record is
# indistinguishable from a bug, and it cannot be repaired later.
raise RuntimeError("no authenticated identity on a served request")
return ServeContext(
domain=req.route.domain, product=req.route.product,
version=req.route.version, port=req.route.port,
caller=caller, from_cache=req.served_from_cache,
)
Verify that no served request reaches the emitter without an identity:
curl -sS "$PROM/api/v1/query" --data-urlencode \
'query=sum(rate(tile_responses_total{caller=""}[5m]))' | jq -r '.data.result[0].value[1] // "0"'
# expected: 0
2. Emit cost at the point it is incurred
Deriving cost later from request counts loses the distinction between a cache hit and a render, which is the largest single factor in what a request costs.
# serve_cost.py — cost recorded per response, in the units the rate card publishes.
from prometheus_client import Counter
SERVE_COST = Counter(
"spatial_serving_cost_micros_total",
"Serving cost in millionths of a currency unit",
labelnames=("domain", "product", "version", "port", "caller", "category", "rate_card"),
)
TILES = Counter(
"spatial_tiles_served_total", "Tiles served",
labelnames=("domain", "product", "port", "caller", "category"),
)
RATE_CARD = "2026-07"
UNIT = {"render_cpu_ms": 0.42, "cache_lookup": 0.0004, "egress_byte": 0.00009}
def record(ctx, cpu_ms: float, bytes_out: int) -> None:
"""A cache hit costs a lookup plus egress; a miss costs the render as well.
Recording them under one category would hide the entire value of the cache."""
category = "cache" if ctx.from_cache else "render"
micros = (UNIT["cache_lookup"] if ctx.from_cache else cpu_ms * UNIT["render_cpu_ms"])
micros += bytes_out * UNIT["egress_byte"]
SERVE_COST.labels(
domain=ctx.domain, product=ctx.product, version=ctx.version,
port=ctx.port, caller=ctx.caller, category=category, rate_card=RATE_CARD,
).inc(micros)
TILES.labels(
domain=ctx.domain, product=ctx.product, port=ctx.port,
caller=ctx.caller, category=category,
).inc()
Verify the label cardinality stays bounded before this runs in production:
curl -sS "$PROM/api/v1/query" --data-urlencode \
'query=count(count by (caller) (spatial_serving_cost_micros_total))' | jq -r '.data.result[0].value[1]'
# Dozens is healthy. Thousands means end users are being labelled, not services.
3. Compute each consumer’s share
Share, not absolute spend. A consumer costing a great deal because they serve enormous valuable traffic is not a problem; one costing 60% of a product’s budget while representing 2% of its value is.
# consumer-cost-rules.yaml
groups:
- name: spatial_consumer_cost
interval: 60s
rules:
- record: spatial:consumer_cost_share:1h
expr: |
sum(rate(spatial_serving_cost_micros_total[1h])) by (domain, product, caller)
/ ignoring(caller) group_left
sum(rate(spatial_serving_cost_micros_total[1h])) by (domain, product)
# Cost per tile, per consumer — the number that exposes an access-pattern mismatch.
- record: spatial:consumer_cost_per_tile:1h
expr: |
sum(rate(spatial_serving_cost_micros_total[1h])) by (domain, product, caller)
/ sum(rate(spatial_tiles_served_total[1h])) by (domain, product, caller)
# A consumer's cache hit ratio. Far below the product's average means they are
# requesting tiles nobody else does — usually a bulk extract through a tile port.
- record: spatial:consumer_cache_ratio:1h
expr: |
sum(rate(spatial_tiles_served_total{category="cache"}[1h])) by (domain, product, caller)
/ sum(rate(spatial_tiles_served_total[1h])) by (domain, product, caller)
- name: spatial_consumer_cost_alerts
rules:
- alert: ConsumerDominatesProductCost
expr: spatial:consumer_cost_share:1h > 0.4
for: 2h
labels: { severity: ticket }
annotations:
summary: "{{ $labels.caller }} is {{ $value | humanizePercentage }} of {{ $labels.product }}"
description: "Check their cache ratio — a low one means they need a bulk port."
- alert: ConsumerAccessPatternMismatch
expr: |
spatial:consumer_cache_ratio:1h < 0.3
and on(domain, product, caller) rate(spatial_tiles_served_total[1h]) > 5
for: 1h
labels: { severity: ticket }
annotations:
summary: "{{ $labels.caller }} has a {{ $value | humanizePercentage }} cache ratio"
description: "Sequential coverage of a tile port is a bulk extract in disguise."
Verify the alert identifies a known bulk consumer before you trust it on unknown ones:
curl -sS "$PROM/api/v1/query" --data-urlencode \
'query=topk(5, spatial:consumer_cost_share:1h{product="'"$PRODUCT"'"})' \
| jq -r '.data.result[] | "\(.metric.caller) \(.value[1])"'
4. Act on the answer by offering a port, not a limit
The finding is almost never “this consumer is wasteful”. It is “this consumer is using the wrong port”, and the fix is on the producer’s side.
# A consumer with a low cache ratio and high sequential coverage is building an
# extract. Confirm the pattern before the conversation.
curl -sS "$PROM/api/v1/query" --data-urlencode \
'query=sum by (z) (rate(spatial_tiles_served_total{caller="'"$CALLER"'"}[1h]))' \
| jq -r '.data.result[] | "z\(.metric.z) \(.value[1])"' | sort -V
# Flat volume across every zoom is coverage, not browsing. Offer the snapshot port.
Configuration Reference
| Label / setting | Value | Effect | Omission |
|---|---|---|---|
caller |
Workload identity | The whole point of attribution | Records unattributable forever |
category |
cache / render |
Separates a hit from a miss | Hides the cache’s entire value |
rate_card |
Version string | Ratios stay comparable across repricing | A step change everyone learns to ignore |
version |
Product version | Catches a costly new release | Regressions look like growth |
| Cardinality ceiling | ~100 callers | Keeps the metric affordable | Prometheus becomes the biggest cost |
| Emission point | The response path | Cost includes cache/render distinction | Later derivation loses it |
| Retention | 13 months | Year-over-year comparison | Seasonal patterns invisible |
Common Failure Modes & Fixes
A large share of cost carries an empty caller. Root cause: a serving path that bypasses the edge — a health check, an internal warm-up job, a legacy route. Fix: give those paths their own service identity rather than letting them record as empty; “unknown” is not a consumer.
Prometheus memory grows sharply after enabling attribution. Root cause: caller cardinality is unbounded, usually because end-user identities are being used. Fix: map identities to a bounded set of service or team roles at the edge before labelling.
Cost per tile jumps for every consumer on the same day.
Root cause: a rate-card change, not a behaviour change. Fix: the rate_card label distinguishes the two; without it this is indistinguishable from a genuine regression.
One consumer’s share is high and their cache ratio is also high. Root cause: they are simply a large, well-behaved consumer. Fix: nothing — this is what a successful product looks like, and the alert threshold may need raising for that product.
Attribution is complete and nobody looks at it. Root cause: it lives on a separate cost dashboard rather than beside latency and freshness. Fix: put the ratios on the product’s own dashboard; cost noticed alongside reliability gets acted on, cost in its own report does not.
FAQ
Why attribute cost if we do not charge anyone for it?
Because the measurement changes behaviour on its own, and chargeback is a separate, harder organisational decision that attribution does not require. A team that can see it is issuing forty thousand tile requests a night to assemble a nightly extract will ask for the extract, because that is obviously better for them too — faster, more reliable, and less code. The conversation is about fit rather than money, and it is available as soon as the numbers exist. Building attribution first and deciding about chargeback later is the right sequence; the reverse stalls on a policy question before any data exists to inform it.
Is per-consumer cost data sensitive?
Yes, and it needs the same access controls as the access logs it derives from. A record of which consumer queried which product, over which extent, at what volume, describes what that consumer is working on — which is why a domain should see its own products’ consumer breakdown and the platform team should see the estate, but one domain browsing another’s consumer list is an information leak rendered as a dashboard. The aggregate ratios are safe to publish widely; the per-caller detail is not.
What granularity should the caller label use?
The coarsest granularity that still identifies who to talk to, which is almost always a service or team identity rather than an individual or an instance. Per-instance labels multiply cardinality by the replica count and tell you nothing actionable — you cannot have a conversation with a pod. Per-end-user labels are both unbounded and sensitive. A few dozen service identities across an estate is the right order of magnitude, and it keeps the metric cheap enough to retain for a year.
How do I handle cost for a consumer that goes through a shared gateway?
Make the gateway propagate the original identity rather than substituting its own, or every downstream consumer collapses into one entry called “gateway”. Where the gateway genuinely cannot — a public endpoint, an unauthenticated path — record that as its own named identity and accept that the traffic behind it is unattributable. What matters is that the unattributable share is visible and bounded, so nobody mistakes a large “gateway” entry for a single very expensive consumer.
Related
- Cost Observability for Spatial Workloads — the parent topic and the full indicator set
- Measuring Cost per Thousand Tiles in Prometheus — the product-level ratio these labels feed
- Product Thinking for GIS Datasets — why the answer is usually a different port