Per-Tenant Cost Attribution on Shared Infrastructure: Rules, Idle Capacity, and Reconciliation

Attributing shared cloud cost to tenants is a set of choices. A worked lab shows one tenant's margin moving from 54% to -245% depending only on the allocation rule.

A company's overall gross margin can look healthy while a few customers cost more to serve than they pay. On shared infrastructure you cannot see this from the cloud bill, because the bill describes machines, databases, and data transfer, not customers. Attributing cost to tenants means choosing allocation rules, and those rules are choices, not facts.

This guide covers what to include, how to measure each tenant's share, how to treat capacity nobody is using, and how to check the result against the invoice. The lab takes one invented month for four tenants and allocates the same $30,000 of shared cost three ways. One tenant's margin ranges from 54% to -245% depending only on the rule.

Decide what the number is for

There are two different numbers, and mixing them causes arguments:

  • Cost of revenue in your financial statements follows your accounting policy and is decided with your accountants. Which salaries, support costs, and fees belong there is an accounting question.
  • Tenant unit economics for engineering and pricing is an internal model. It can include or exclude anything, as long as the definition is written down and applied consistently.

This guide is about the second. Label every report with its definition, and do not present an internal allocation as the audited figure.

What to include

Cost How to attribute it Typical driver
Dedicated resources (a tenant's own database, bucket, or cluster) Directly, by resource tag None needed
Shared compute Proportionally CPU seconds or request time per tenant
Shared database Proportionally Query time, rows read, or storage per tenant
Storage Proportionally Bytes stored per tenant
Data transfer Proportionally, by traffic type Bytes per tenant, split into internet egress and cross-zone traffic
Third-party APIs (email, SMS, AI models) Directly, from your own call logs Units billed per call
Idle or reserved capacity Shown separately, or spread by a stated rule See below

Tag dedicated resources with the tenant ID and activate the tags for billing, as described in AWS's cost allocation tags documentation. For Kubernetes workloads, OpenCost allocates cluster cost to namespaces, labels, and pods, which you can map to tenants if you run tenant-specific workloads.

Measuring each tenant's share

The shared lines need a driver per tenant. Record it where the work happens:

  • Database time: wrap query execution in your data access layer, measure duration, and add it to a per-tenant counter. Write these totals to logs or a usage table, not to a metric label per tenant; the observability guide explains why.
  • Request time or CPU: measure handler duration per tenant in middleware. CPU time is closer to cost, but request time is easier and good enough to start.
  • Bytes: record response sizes per tenant at your load balancer or ingress, and object sizes in storage.

A small recorder for per-tenant drivers

The package below keeps running totals per tenant in memory (database time, query count, and response bytes) and flushes them once per interval as one row per tenant. Write those rows to a usage table or a log stream that your cost job reads. Because tenants are rows rather than metric labels, a thousand tenants cost a thousand small rows per interval, not a thousand new time series.

// Package tenantusage accumulates per-tenant cost drivers in memory and
// flushes them as rows, one per tenant and interval, instead of as metric
// labels. Rows go to a usage table or log stream that cost attribution reads.
package tenantusage

import (
	"context"
	"database/sql"
	"net/http"
	"sort"
	"sync"
	"time"
)

type Totals struct {
	DBTime        time.Duration
	Queries       int64
	ResponseBytes int64
}

type Row struct {
	Tenant string
	Start  time.Time
	Totals
}

type Recorder struct {
	mu    sync.Mutex
	start time.Time
	by    map[string]*Totals
}

func NewRecorder(now time.Time) *Recorder {
	return &Recorder{start: now, by: map[string]*Totals{}}
}

func (r *Recorder) add(tenant string, f func(*Totals)) {
	r.mu.Lock()
	t := r.by[tenant]
	if t == nil {
		t = &Totals{}
		r.by[tenant] = t
	}
	f(t)
	r.mu.Unlock()
}

// Query runs a query for a tenant and records how long the database took.
// In a real service this lives inside the tenant-scoped data access package.
func (r *Recorder) Query(ctx context.Context, tx *sql.Tx, tenant, query string, args ...any) (*sql.Rows, error) {
	start := time.Now()
	rows, err := tx.QueryContext(ctx, query, args...)
	r.ObserveQuery(tenant, time.Since(start))
	return rows, err
}

func (r *Recorder) ObserveQuery(tenant string, d time.Duration) {
	r.add(tenant, func(t *Totals) { t.DBTime += d; t.Queries++ })
}

// Middleware records response bytes for the tenant found by tenantOf.
func (r *Recorder) Middleware(tenantOf func(*http.Request) string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		cw := &countingWriter{ResponseWriter: w}
		next.ServeHTTP(cw, req)
		if t := tenantOf(req); t != "" {
			r.add(t, func(x *Totals) { x.ResponseBytes += cw.n })
		}
	})
}

type countingWriter struct {
	http.ResponseWriter
	n int64
}

func (c *countingWriter) Write(b []byte) (int, error) {
	n, err := c.ResponseWriter.Write(b)
	c.n += int64(n)
	return n, err
}

// Flush returns one row per tenant for the interval and starts a new one.
func (r *Recorder) Flush(now time.Time) []Row {
	r.mu.Lock()
	by, start := r.by, r.start
	r.by, r.start = map[string]*Totals{}, now
	r.mu.Unlock()
	rows := make([]Row, 0, len(by))
	for tenant, t := range by {
		rows = append(rows, Row{Tenant: tenant, Start: start, Totals: *t})
	}
	sort.Slice(rows, func(i, j int) bool { return rows[i].Tenant < rows[j].Tenant })
	return rows
}

The test runs 50 goroutines recording 1,000 queries each for two tenants, sends four 512-byte responses for one tenant, and checks the flushed totals: exactly 25,000 queries and 25 seconds of database time per tenant, 2,048 bytes for the first, and an empty second flush. It passes with Go 1.26 (go test reported ok on November 3, 2026). The recorder measures wall-clock time around each query, which includes network and queueing time; that is usually the right driver for "how much of the database did this tenant occupy", but say so in the report.

package tenantusage

import (
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
	"time"
)

func TestConcurrentTotalsAndFlush(t *testing.T) {
	t0 := time.Date(2026, 11, 3, 10, 0, 0, 0, time.UTC)
	r := NewRecorder(t0)
	var wg sync.WaitGroup
	for g := 0; g < 50; g++ { // 50 goroutines x 1,000 observations
		wg.Add(1)
		go func(g int) {
			defer wg.Done()
			tenant := []string{"tnt_a", "tnt_b"}[g%2]
			for i := 0; i < 1000; i++ {
				r.ObserveQuery(tenant, time.Millisecond)
			}
		}(g)
	}
	h := r.Middleware(func(req *http.Request) string { return req.Header.Get("X-Test-Tenant") },
		http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte(strings.Repeat("x", 512))) }))
	for i := 0; i < 4; i++ {
		req := httptest.NewRequest("GET", "/", nil)
		req.Header.Set("X-Test-Tenant", "tnt_a")
		h.ServeHTTP(httptest.NewRecorder(), req)
	}
	wg.Wait()

	rows := r.Flush(t0.Add(time.Minute))
	if len(rows) != 2 || rows[0].Tenant != "tnt_a" || rows[1].Tenant != "tnt_b" {
		t.Fatalf("rows: %+v", rows)
	}
	for _, row := range rows {
		if row.Queries != 25_000 || row.DBTime != 25*time.Second {
			t.Errorf("%s: %d queries, %v", row.Tenant, row.Queries, row.DBTime)
		}
		if !row.Start.Equal(t0) {
			t.Errorf("%s: interval start %v", row.Tenant, row.Start)
		}
	}
	if rows[0].ResponseBytes != 2048 || rows[1].ResponseBytes != 0 {
		t.Errorf("bytes: %d, %d", rows[0].ResponseBytes, rows[1].ResponseBytes)
	}
	if next := r.Flush(t0.Add(2 * time.Minute)); len(next) != 0 {
		t.Errorf("second flush should be empty, got %+v", next)
	}
}

Cross-zone transfer deserves its own line. AWS's on-demand pricing page states that data transferred between Availability Zones in the same Region is charged at $0.01 per GB in each direction, which is easy to miss in a design where every request crosses zones to reach a cache or replica.

Lab: one month, three allocation rules

All figures in the lab are invented. Four rows stand in for 61 tenants. Shared compute costs $18,000 and the shared database $12,000; measured utilization of that capacity averaged 62%. The program allocates the shared cost three ways:

  1. Equal split across all 61 tenants.
  2. Usage-weighted: each tenant's measured share of CPU and database time, applied to the full cost.
  3. Usage-weighted, idle kept separate: the same shares applied only to the 62% of cost that was used; the idle 38% is reported as its own line.

Direct per-tenant costs are added in every case. Run it with go run .; it uses only the standard library.

// Lab: one hypothetical month of shared-cost allocation, three ways.
//
// All figures are invented for illustration. They show how the allocation
// rule, not the infrastructure, changes the margin a team reports.
package main

import "fmt"

type tenant struct {
	name             string
	revenue          float64
	cpuShare, dbTime float64 // measured shares of active usage (0-1)
	direct           float64 // costs that are already per-tenant (e.g. third-party API fees)
}

func main() {
	const compute, database = 18000.0, 12000.0 // shared monthly invoice lines
	const utilisation = 0.62                   // measured average share of shared capacity in active use
	tenants := []tenant{
		{"tenant A (large, steady)", 9000, 0.30, 0.20, 400},
		{"tenant B (small, heavy reports)", 1200, 0.08, 0.22, 60},
		{"tenant C (mid-size)", 4000, 0.12, 0.10, 150},
		{"58 other tenants combined", 21000, 0.50, 0.48, 900},
	}
	n := 61.0
	fmt.Printf("%-32s %9s | %-21s | %-21s | %-21s\n", "", "revenue", "equal split", "usage-weighted", "usage-weighted, idle kept separate")
	var idle float64
	for _, t := range tenants {
		count := 1.0
		if t.name == "58 other tenants combined" {
			count = 58
		}
		equal := (compute+database)*count/n + t.direct
		usage := compute*t.cpuShare + database*t.dbTime + t.direct
		active := (compute*t.cpuShare+database*t.dbTime)*utilisation + t.direct
		idle += (compute*t.cpuShare + database*t.dbTime) * (1 - utilisation)
		m := func(c float64) string { return fmt.Sprintf("$%7.0f  %6.1f%%", c, 100*(t.revenue-c)/t.revenue) }
		fmt.Printf("%-32s %9.0f | %-21s | %-21s | %-21s\n", t.name, t.revenue, m(equal), m(usage), m(active))
	}
	fmt.Printf("\nunallocated idle capacity reported separately: $%.0f of $%.0f shared cost\n", idle, compute+database)
}

Output recorded on November 3, 2026 (Go 1.26.5, windows/amd64):

                                   revenue | equal split           | usage-weighted        | usage-weighted, idle kept separate
tenant A (large, steady)              9000 | $    892    90.1%     | $   8200     8.9%     | $   5236    41.8%
tenant B (small, heavy reports)       1200 | $    552    54.0%     | $   4140  -245.0%     | $   2590  -115.8%
tenant C (mid-size)                   4000 | $    642    84.0%     | $   3510    12.2%     | $   2233    44.2%
58 other tenants combined            21000 | $  29425   -40.1%     | $  15660    25.4%     | $  10051    52.1%

unallocated idle capacity reported separately: $11400 of $30000 shared cost

What the output shows

The rule changes the conclusion. Tenant B, small but running heavy reports, looks like a 54% margin customer under an equal split and a -245% margin customer under usage weighting. The infrastructure did not change.

Equal splits punish small tenants as a group. Under the equal split, the 58 small tenants together show a -40% margin, which would suggest raising prices for exactly the customers who use the least.

Spreading idle capacity hides an engineering problem. In the second view, $11,400 of idle capacity is charged to tenants as if they had used it. In the third view, tenant margins reflect what they used, and the idle line shows what over-provisioning costs. Report both views: one reconciles to the invoice, the other shows efficiency.

Reconcile to the invoice

Whatever the rules, the total allocated plus any separately reported idle cost must equal the bill for the same period. Load your provider's billing export (on AWS, Data Exports, which includes the Cost and Usage Report) into a database, map line items to your cost categories, and check the sums every month before anyone reads tenant margins. A model that does not reconcile is a guess.

Using the result

A negative margin is a question, not a verdict. Look at the cause first: an unpaginated integration polling every few seconds, years of retained data the customer never reads, or a report that should be cached. Often the fix is engineering. When the usage is legitimate and simply larger than the plan anticipated, you have evidence for a pricing conversation, a usage-based component, or a limit, ideally before renewal.

The unit economics calculator performs the equal and usage-weighted calculation for one tenant with your own numbers, and the metering guide covers collecting per-tenant usage reliably enough to bill from.

Where to start

Pick one shared cost line, usually the database, and one driver you can measure this week, usually query time per tenant. Allocate last month's database bill by that driver, show idle capacity separately, and compare the result with an equal split. The tenants whose rank changes most are the ones worth investigating first.

Frequently Asked Questions

Which driver is best for a shared database?

Query time is a reasonable start because it tracks load on the database. Storage per tenant matters more for storage-heavy products. Use the driver that best explains your bill's largest line, and write down why.

Should support salaries be included?

In an internal model you can include support time per tenant if you track it. Whether those salaries belong in reported cost of revenue is an accounting policy question for your finance team.

How precise does this need to be?

Precise enough to rank tenants and to catch those far from the average. Errors of a few percent rarely change a decision; a wrong rule, such as an equal split, can reverse it.

What about reserved instances and savings plans?

Use the amortized cost from your billing export so tenants are charged the effective rate, and keep unused commitment in the idle line rather than spreading it across tenants.

How often should we calculate this?

Monthly, aligned with the billing export, is enough for pricing and renewals. Daily estimates are useful for spotting a tenant whose usage changed suddenly, as long as they are labeled as estimates.

Sources

Documents cited in this guide. Each link was opened and its title compared with the label below on the date shown. A source supports the specific statements it is linked from; design recommendations and lab results are Velynoriva's own.

Revision history

  • — Replaced a table of invented customers that was presented as real data with a clearly labeled hypothetical lab. Removed invented anecdotes and benchmark labels, TeX formulas that displayed as raw text, and advice about replication and peering that did not reduce egress charges. Corrected cross-zone transfer pricing to $0.01 per GB in each direction. Added a tested Go recorder for per-tenant database time and response bytes.
  • — Separated internal allocation from financial statement treatment.