Usage metering & budget policies
The durable, tenant-scoped usage plane — an append-only event stream over a closed metric catalog, bounded rollups, and per-tenant budget policies that warn, shadow-deny, or hard-deny to cap spend and volume.
Usage metering is the measured side of the settled stage — it prices what work consumed, in exact units, for every Worker and surface. It answers two operator questions for every tenant: how much did they consume and should the next unit be allowed. It records tenant-scoped, append-only usage events over a closed metric catalog — routing decisions, connector actions, workflow steps, MCP tool calls, and AI tokens in and out are all metered today — aggregates them into bounded rollups, and enforces per-tenant budget policies that can warn, shadow-deny, or hard-deny. This is the plane you use to cap spend and volume per tenant.
Every read and write is tenant-scoped and gated by the usage authz resource
type. In production, authenticate as described in
Authentication; the local dev examples below run with auth
disabled and pass the tenant explicitly via X-Tenant-ID.
The three planes
- Events — an immutable, append-only record of one metered occurrence.
- Rollups — bounded aggregations of events over a window, carrying summed value, event count, and cost.
- Budget policies — per-tenant caps that match events and stamp an outcome onto each recorded event.
This page owns the durable, calendar-windowed budgets
Several surfaces feed this plane, and one of them (the AI inference proxy) has
a separate in-process cost guard that is easy to confuse with a budget
policy. The durable, tenant-scoped, calendar-windowed caps are the
UsageBudgetPolicy records described here. See
In-process vs. durable budgets.
The metric catalog (closed set)
Metering accepts only keys from a fixed catalog. An event or policy referencing
any other metric_key is rejected at validation. List the live catalog from
GET /api/usage/metrics; each descriptor carries a canonical surface and
unit.
| Metric key | Surface | Unit | Records |
|---|---|---|---|
routing.decisions | routing | decision | Committed routing decisions. |
routing.strategy_evaluations | strategy | evaluation | Strategy selection evaluations. |
connector.actions | connector_action | call | Connector action executions. |
connector.proxy_requests | connector_proxy | request | Connector proxy requests. |
connector.sync_records | connector_sync | record | Connector sync records processed. |
connector.sync_run_leases | connector_sync | decision | Connector sync run lease decisions. |
connector.sync_retention_runs | connector_sync | run | Sync record retention prune/sweep runs. |
connector.function_invocations | connector_function | invocation | Connector function runtime invocations. |
connector.execution_locks | connector_function | decision | Function execution lock decisions. |
connector.execution_lifecycles | connector_function | transition | Execution lifecycle transitions/cleanup. |
connector.artifact.upload_bytes | connector_artifact | byte | Artifact bytes uploaded or captured. |
connector.artifact.download_bytes | connector_artifact | byte | Artifact bytes downloaded. |
connector.artifact.storage_bytes | connector_artifact | byte | Artifact bytes for storage budgeting. |
workflow.runs | workflow | run | Workflow runs started or completed. |
workflow.steps | workflow | step | Workflow step executions. |
workflow.schedule_ticks | workflow_schedule | tick | Workflow schedule ticks. |
mcp.tool_calls | mcp | call | MCP tool calls. |
ai.requests | ai_inference | request | AI inference requests. |
ai.tokens.input | ai_inference | token | AI inference input tokens. |
ai.tokens.output | ai_inference | token | AI inference output tokens. |
ai.cost.usd | ai_inference | usd | Estimated AI inference cost in USD. |
ai.latency.ms | ai_inference | ms | End-to-end latency for each provider attempt. |
ai.errors | ai_inference | error | Failed provider attempts. |
policy.evaluations | policy | evaluation | Policy evaluation executions. |
The usage event
A UsageEvent is one metered occurrence. It is validated on record and again
during preview.
Prop
Type
correlation carries decision_id, routable_id, pool_id, recipient_id,
workflow_id, workflow_run_id, workflow_step_id, agent_definition_id,
agent_session_id, experiment_id, experiment_assignment_id,
connector_execution_id, sync_run_id, function_run_id,
promotion_candidate_id, mcp_request_id, and trace_id — every one optional,
so an event can be traced back to what produced it.
Dimension safety: allow-list, no secrets, no payloads
Dimension keys must be in a fixed allow-list (for example provider_key,
connection_id, action_key, workflow_id, model, ai_provider,
agent_definition_id, agent_session_id, experiment_id,
experiment_assignment_id, mcp_tool, pool_id, recipient_id,
route_outcome, strategy,
environment, outcome, source). Arbitrary keys are opt-in only under the
custom. prefix. Validation rejects keys or values that look like secrets
or PII (password, token, secret, authorization, ssn, credit_card,
prompt, completion, …) and rejects raw JSON payloads (values wrapped in
{} or []) or bearer/basic/api_key= material. Limits: at most 32
dimensions, keys ≤ 96 chars, values ≤ 256 chars. Keep usage attribution
low-cardinality and non-sensitive.
Budget policies
A UsageBudgetPolicy is a per-tenant cap. Its match key is the tuple
metric_key + surface + unit + dimensions; a policy matches an event when
those coordinates agree and every policy dimension is present with the same
value on the event (the event may carry extra dimensions).
Prop
Type
Windows are UTC calendar-aligned: hour truncates to the hour, day to the UTC
day, week to the Monday-start week, month to the first of the month. The
window that a given event falls into is derived from its observed_at.
The three actions
| Action | At/over the limit | Blocks the caller? |
|---|---|---|
warn | Records the event, logs the breach, stamps warned. | No — observe only. |
shadow_deny | Records the event, stamps shadow_denied. | No — simulates a denial so you can size a cap before enforcing it. |
deny | Stamps denied; Allowed is false. | Yes — a hard cap. |
shadow_deny is the safe way to roll out a cap: you see exactly which events
would have been blocked without actually blocking anything, then promote the
policy to deny once the limit is calibrated.
Enforcement
On every recorded event, the service loads matching policies, sums prior usage over each policy's window, and computes a projection.
For each matching policy: projected = used + value, where used is the sum of
prior in-window usage.
| Condition | Reason | Resulting status |
|---|---|---|
projected < limit * threshold_pct | within_budget | recorded |
projected >= limit * threshold_pct (but < limit) | budget_threshold_reached | warned |
projected >= limit, action deny | budget_exhausted | denied |
projected >= limit, action shadow_deny | budget_shadow_exhausted | shadow_denied |
projected >= limit, action warn | budget_exhausted_warn | warned |
When several policies match one event, the most restrictive outcome wins and
is stamped onto the event, in precedence order denied > shadow_denied >
warned > recorded.
threshold_pct of 0 means no early warning
The warn threshold is limit * threshold_pct. If threshold_pct is unset (0),
it is treated as the full limit — so you get no pre-exhaustion warning, only
the at-limit outcome. Set threshold_pct to, say, 0.8 to be warned at 80% of
the cap.
Preview: a read-only admission check
POST /api/usage:preview runs the exact same budget evaluation as recording
does, but persists nothing. It returns the projected outcome — allowed, a
status, and the per-policy decisions — so a caller can decide whether to
proceed before doing metered work.
allowed is false only when the projected status is denied; warned and
shadow_denied previews are still allowed.
HTTP API
All endpoints are tenant-scoped and gated by the usage resource type
(UsageService). Reads use read, writes use write. Rollup and status pages
default to 100 rows and cap at 500.
| Method & path | RPC | Purpose |
|---|---|---|
GET /api/usage/metrics | ListUsageMetrics | The closed metric catalog / descriptors. |
GET /api/usage | QueryUsage | Bounded rollups; default 24h window; include_events adds raw events. |
POST /api/usage:preview | PreviewUsageImpact | Dry-run admission check; warn/shadow/deny outcome without recording. |
POST /api/usage/budget-policies | UpsertUsageBudgetPolicy | Create or update a policy. |
GET /api/usage/budget-policies/{policy_id} | GetUsageBudgetPolicy | Fetch one policy. |
GET /api/usage/budget-policies | ListUsageBudgetPolicies | List policies for the tenant. |
DELETE /api/usage/budget-policies/{policy_id} | DeleteUsageBudgetPolicy | Delete one policy. |
GET /api/usage/budget-status | GetUsageBudgetStatus | Recent per-window budget states. |
QueryUsage bounds itself: if to is omitted it defaults to now, and if from
is omitted it defaults to 24 hours before to. Both QueryUsage and
GetUsageBudgetStatus rollups carry cost_micros, making them the per-tenant
usage/cost reporting endpoints.
curl "https://your-host/api/usage/metrics" \
-H "X-Tenant-ID: $DUCTOR_TENANT_ID"# Last 24h of AI cost, with raw events included
curl "https://your-host/api/usage?metric_key=ai.cost.usd&include_events=true" \
-H "X-Tenant-ID: $DUCTOR_TENANT_ID"curl "https://your-host/api/usage:preview" \
-H "X-Tenant-ID: $DUCTOR_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"metric_key": "connector.actions",
"surface": "connector_action",
"value": 1,
"unit": "call"
}'# Hard-cap connector actions at 10,000/day, warn at 80%
curl "https://your-host/api/usage/budget-policies" \
-H "X-Tenant-ID: $DUCTOR_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"metric_key": "connector.actions",
"surface": "connector_action",
"unit": "call",
"window": "day",
"limit": 10000,
"action": "deny",
"threshold_pct": 0.8
}'curl "https://your-host/api/usage/budget-status?metric_key=connector.actions" \
-H "X-Tenant-ID: $DUCTOR_TENANT_ID"The same operations are exposed on the MCP operational surface as
usage_metrics_list, usage_query, usage_budget_preview,
usage_budget_policy_upsert, usage_budget_policy_delete,
usage_budget_policies_list, and usage_budget_status.
Setting a cap
Confirm the metric
GET /api/usage/metrics and pick the metric_key, surface, and unit you
want to cap — the policy must match those coordinates exactly.
Observe first with shadow_deny
Upsert the policy with action: "shadow_deny" and your candidate limit. Watch
GET /api/usage/budget-status and events stamped shadow_denied to see what
would have been blocked.
Promote to deny
Once the limit is calibrated, upsert the same policy with action: "deny".
Matching events at/over the limit are now stamped denied and callers that
preview see allowed: false.
Monitor
GET /api/usage/budget-status returns recent per-window states — used,
limit, remaining — so you can alert before a tenant hits the wall.
Wiring & availability
No Postgres = silently no-op budgets
The usage module is always registered, but the store is Postgres-gated: it is built only when a database pool is present. With no pool, the store is nil, which makes the service nil. The result: the HTTP/MCP API returns service unavailable, and every internal metering hook (routing recording, the AI inference bridge, connector admission) silently does nothing — budget policies never fire. If enforcement seems to have no effect, verify the database is configured first.
How the plane is fed
Metering is not called by hand from most places — surfaces emit into it.
Routing
The routing finalizer records routing.decisions for every committed decision
via its usage recorder, attaching pool_id, recipient_id, route_outcome,
and strategy dimensions plus decision/trace correlation. Dry-run decisions are
skipped. See the Routing pipeline.
Connector execution
Connector execution admission calls PreviewUsageImpact as a fact check before
running an action, sync, or proxy request. If the projected outcome is a
denial, admission is blocked by budget (connector execution blocked by budget). The metered amount per execution is the request's cost_units
(default 1). This is where usage budgets meet connector cost accounting — see
the Sync engine.
AI inference
The AI inference proxy bridges measured usage into this durable plane. Completed responses emit request, input-token, output-token, and cost events. Every provider attempt emits latency; failed attempts also emit a request and an error event. This preserves retry and failover visibility instead of collapsing a multi-provider request into one opaque total.
| Metric | When it is recorded |
|---|---|
ai.requests | Once for a completed response, or once for each failed provider attempt. |
ai.tokens.input | When the provider reports prompt/input tokens. |
ai.tokens.output | When the provider reports completion/output tokens. |
ai.cost.usd | When Ductor can calculate a positive estimate from reported usage. |
ai.latency.ms | For every provider attempt, including failed attempts. |
ai.errors | For every provider attempt whose outcome is error. |
AI events carry ai_provider, model, and outcome dimensions. Callers can
also supply bounded attribution for agent_definition_id, agent_session_id,
workflow_run_id, workflow_step_id, experiment_id, and
experiment_assignment_id. Ductor copies those values into both safe
dimensions and the typed correlation object, so one cost or latency spike can
be traced to the agent, workflow step, and experiment exposure that produced
it. Each value is limited to 256 bytes and cannot contain control delimiters.
Cost-bearing events include amount_micros, currency: "USD", the source
ductor_static_model_cost_table, and a pricing-table version. Treat this as a
versioned Ductor estimate for attribution and budgets, not as a provider
invoice. The provenance fields let you recompute or explain historical totals
after pricing data changes.
Untenanted inference is not durably metered
The AI bridge emits durable usage only when a tenant id is present in the request context. Inference without a tenant is cost-tracked in-process for the guards below, but never written to the durable usage plane.
In-process vs. durable budgets
The AI inference proxy also carries its own, separate cost guards — do not confuse them with budget policies.
| Budget policies (this page) | In-process AI guards | |
|---|---|---|
| Scope | Per tenant, in the database | Per process, single instance |
| Window | UTC calendar (hour/day/week/month) | Process lifetime — cumulative, not a calendar rollup |
| Config | UsageBudgetPolicy records via the API | ai.routing.max_cost_per_decision_usd, ai.routing.monthly_budget_usd |
| Default | Off until you create a policy | Off (0) unless set; the enterprise security profile sets nonzero defaults |
| Enforcement | warn / shadow_deny / deny per policy | Rejects a decision over the per-decision or cumulative cap |
The "monthly" in-process guard is process-lifetime cumulative spend, not a
calendar month. For durable, tenant-scoped, calendar-windowed month caps, use a
UsageBudgetPolicy with window: "month". For the in-process guards, see the
Inference proxy.
Cost reporting to invoicing
QueryUsage and GetUsageBudgetStatus rollups carry cost_micros, so they are
the per-tenant usage and cost reporting endpoints. Accrued billable usage —
usd-unit cost rows — can be swept into invoices by the usage-invoicing worker,
which is experimental and opt-in (gated off by default). For that pipeline, see
the Invoicing integration.
Related
Plans, Entitlements & Clearing Limits
A plan is not a separate system — it is an entitlement item; how tier gating, enforcement postures, and provider-driven lifecycle add to the entitlement model.
Commerce & pricing
Per-recipient pricing and budgets, returns with credits, and compliance reporting over routed decisions.