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.

The identity has to survive from the edge to the emitter, or the record is lostA caller presents a client certificate at the edge, which authenticates it and resolves a workload identity. The edge sets that identity as a header on the forwarded request. The tile service reads it from the header — never from a client-supplied value — and passes it to the cost emitter, which labels the metric with it. The final exchange records the failure this prevents: a serving path that bypasses the edge produces a record with an empty caller, and cost records cannot be enriched afterwards.CallerEdge (mTLS)Tile serviceCost emitterrequest + client certauthenticate → identityx-authenticated-identitylabel the metricbypass path → empty callerunrecoverable

python
# 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:

bash
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.

What a cache hit and a render actually cost, per tileFour cost components in millionths of a currency unit per tile, against the one-unit reference. A cache lookup costs about 0.0004 and egress about 0.09 for a typical tile. A render costs roughly 20 units of renderer CPU. A render whose PostGIS query missed its index costs around 140. The two orders of magnitude between a hit and a miss are the whole reason the cost record separates the two categories: folding them together hides the cache entire contribution and makes every capacity decision wrong.cache lookup0.0004µegress, typical tile0.09µrender, indexed20µrender, index missed140µsee the plan1 unit

python
# 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:

bash
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.

yaml
# 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:

bash
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.

bash
# 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.

Which identity granularity to label with, and what each costsFour granularities compared on label cardinality, whether the resulting finding is actionable, and sensitivity. A service or team identity produces dozens of label values, names somebody who can be talked to, and is the right choice. A per-instance identity multiplies by the replica count and names a pod nobody can have a conversation with. A per-end-user identity is unbounded and sensitive. A single shared gateway identity is cheap and collapses every consumer behind it into one entry.CardinalityActionableVerdictService / teamdozensyesuse thisPer instance× replicasnowastefulPer end userunboundedpartlysensitive tooShared gatewayonenopropagate instead

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.