Lesson 28 / 32

Tenant-Aware Observability

Tag logs, traces and metrics with tenant context so you can answer which tenants are affected, without exploding metric cardinality.

By · 15 years in web development

Every signal needs a tenant

In a multi-tenant system, "the API is slow" is not actionable; "the API is slow for three tenants on shard 2" is. So tenant context must reach your telemetry. Logs should carry tenant_id as a structured field on every line, added automatically from request context. Traces should carry it as a span attribute so you can filter one tenant's slow requests. Metrics are trickier: tagging every metric with tenant id creates one time series per tenant per metric, which is fine for a few hundred tenants but becomes expensive or impossible with tens of thousands. Common compromises are tenant-level labels only on a few key metrics, labels by plan or shard instead of tenant, and computing per-tenant numbers from logs or traces in a separate analytics store. With this in place you can build per-tenant SLO dashboards, spot noisy neighbours and tell a customer exactly how an incident affected them.

Tenant context flows into telemetry

The request's tenant context is stamped on logs, traces and selected metrics, which then feed per-tenant dashboards and alerts.

Three stages: tenant context on the request, telemetry signals tagged with the tenant, per-tenant dashboards and alerts.
Figure 10.1: tenant context -> tagged logs, traces and metrics -> per-tenant dashboards

Stamping tenant_id on every log line

A logging filter reads the same context variable that the request middleware sets, so no call site has to remember the tenant.

import logging
import sys
from contextvars import ContextVar

current_tenant: ContextVar[str] = ContextVar("current_tenant", default="-")

class TenantFilter(logging.Filter):
    def filter(self, record):
        record.tenant_id = current_tenant.get()   # stamp every record
        return True

handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter("%(levelname)s tenant=%(tenant_id)s %(message)s"))
handler.addFilter(TenantFilter())
log = logging.getLogger("app")
log.addHandler(handler)
log.setLevel(logging.INFO)

log.info("worker started")
current_tenant.set("acme")
log.info("export finished in 840 ms")
current_tenant.set("zen")
log.warning("payment webhook retried")

Output:

INFO tenant=- worker started
INFO tenant=acme export finished in 840 ms
WARNING tenant=zen payment webhook retried

Mind metric cardinality

Each unique combination of label values is a separate time series. tenant_id x endpoint x status across 20,000 tenants can mean millions of series. Keep high-cardinality ids in logs and traces, and aggregate into metrics deliberately.

Quick check: Why is adding `tenant_id` as a label on every metric risky at large scale?

  • Metric systems cannot store strings
  • It creates a time series per tenant per label combination, which can explode storage and query cost
  • It leaks data to other tenants
  • Metrics are not allowed to have labels
Answer

It creates a time series per tenant per label combination, which can explode storage and query cost — Cardinality multiplies with each label. With many tenants, per-tenant labels on every metric can overwhelm the metrics backend, so keep them on a chosen few.