Skip to content

Tenant Isolation & Attribution

Candela supports first-class multitenant governance: every LLM API call is attributed to a downstream customer (tenant), enabling per-tenant cost tracking, spend isolation, and tenant-scoped policy enforcement. Tenant identity flows through the entire governance pipeline — budget gates, audit trails, and policy decisions are all tenant-aware.

Your App (ADK agent / FastAPI / etc.)
│ Baggage: candela.tenant_id=acme-corp
│ X-Candela-Tenant-Id: acme-corp
Candela Proxy ──────────────────────────
│ Extracts tenant_id → validates → attaches to Span
Storage (DuckDB / SQLite / BigQuery)
│ tenant_id column indexed for GROUP BY queries
GetTenantLeaderboard RPC → Dashboard / Billing

The W3C Baggage header carries key-value pairs through distributed traces. If you set candela.tenant_id in baggage at the start of an agent run, it flows through every downstream LLM call automatically — even in multi-hop traces.

Baggage: candela.tenant_id=acme-corp,candela.job_id=eval-42

Baggage takes precedence over the explicit header when both are present.

For non-OTel callers (curl, direct API clients):

X-Candela-Tenant-Id: acme-corp
X-Candela-Job-Id: eval-42

The Enrichment SDKs set both Baggage and explicit headers automatically:

from candela import CandelaSession
session = CandelaSession(tenant_id="acme-corp", job_id="eval-42")
client = OpenAI(
base_url="http://localhost:1234/v1",
http_client=session.httpx_client(),
)

IDs must match [a-zA-Z0-9\-._]{1,128}:

✅ Valid❌ Invalid
acme-corpacme corp (space)
tenant_42acme@corp (@ sign)
trial-NCT01750580“ (empty)
customer.a.b.c../escape (path traversal)

Invalid values are silently discarded — the span is written without a tenant_id. This avoids breaking clients that send malformed headers.


With Baggage, tenant context propagates automatically through multi-agent pipelines:

FastAPI handler
└─ Orchestrator agent (Gemini call ①) ← tenant_id set here
└─ Tool: query_data
└─ Sub-agent (Gemini call ②) ← auto-propagated via Baggage
└─ Tool: find_evidence
└─ Sub-agent (Gemini call ③) ← still propagated

With just headers, you’d need to manually re-attach at every hop. Use Baggage for anything with nested LLM calls.


Terminal window
buf curl --protocol connect \
https://candela.example.com/candela.v1.DashboardService/GetTenantLeaderboard \
-d '{
"project_id": "my-project",
"limit": 10,
"time_range": {
"start": "2026-01-01T00:00:00Z",
"end": "2026-02-01T00:00:00Z"
}
}'

Example output:

acme-corp: $12.45 (342 calls)
trial-NCT01234: $7.23 (198 calls)
azra-health-demo: $3.11 (89 calls)

This endpoint is admin-only.

SELECT
COALESCE(tenant_id, '(unattributed)') AS tenant,
COUNT(*) AS calls,
SUM(input_tokens) AS input_tokens,
SUM(output_tokens) AS output_tokens,
SUM(cost_usd) AS cost_usd
FROM spans
WHERE timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
GROUP BY 1
ORDER BY cost_usd DESC;

When the OTLP sink is enabled, tenant_id and job_id are exported as span attributes:

AttributeSurfaces In
candela.tenant_idDatadog (@candela.tenant_id), Honeycomb, Grafana/Tempo
candela.job_idSame — filterable in all OTel-compatible backends

  • The GetTenantLeaderboard API is admin-only
  • Tenant IDs are validated against [a-zA-Z0-9\-._]{1,128} to prevent injection attacks
  • The proxy strips X-Candela-* headers before forwarding to upstream LLMs — tenant metadata never leaks to providers