Skip to content

Analytics-Only Flag

Use control.analytics_only when a charge item describes a fact that should be queryable in analytics and audit logs but should never charge the customer’s wallet. Examples include supplier cost, internal amortization, audit rollups, and virtual revenue aggregators that would otherwise double-count the underlying token revenue.

The value must be the string "true". A real boolean is rejected by the rule set validator. Anything other than the string "true" falls back to the default and the charge item is debited normally.

{
"name": "total-tokens-rollup",
"metric": "total_tokens",
"type": "number",
"unit": "token",
"quantity_expr": "dec_add(dec_add(dec(items['input_tokens']['quantity']), dec(items['output_tokens']['quantity'])), dec(items['cached_tokens']['quantity']))",
"amount_expr": "dec_add(dec_add(dec(items['input_tokens']['amount']), dec(items['output_tokens']['amount'])), dec(items['cached_tokens']['amount']))",
"currency": "USD",
"control": { "analytics_only": "true" }
}

An analytics_only=true item is still a complete usage and rating fact. It is written to:

  • UsageEvent.ChargeItems, so the normalized event carries it forward;
  • the usage event log and the analytics projection, so the item is queryable in historical analytics;
  • the metrics, group_by, and filters results of POST /v1/projects/:project_id/usage/query, so dashboards and reports can summarize it.

If your virtual metric depends on the amount or unit_price of a same-event item, put the dependency first in charge_item_extractors. The items[metric] snapshot is updated in configuration order, so a later item only sees the rated values of earlier items.

An analytics_only=true item is excluded from the customer money path:

  • it does not resolve a currency-bearing realtime debit route;
  • it does not produce a UsageChargeDetail row with a money amount;
  • it does not produce a UsageFundDebitDetail row;
  • it does not participate in realtime wallet debit;
  • it does not participate in the 5-minute bill reconciliation sweep.

That means the item is still queryable, but it cannot trigger refunds, wallet locks, or overage charges. The remaining currency-bearing items in the same event continue to be rated and debited normally.

How analytics-only differs from allow_voucher

Section titled “How analytics-only differs from allow_voucher”

control.analytics_only and control.allow_voucher are independent flags. They answer different questions:

  • control.analytics_only decides whether the item is part of the customer money path at all. When it is "true", no fund debit and no reconciliation ever touch the item.
  • control.allow_voucher decides the funding source for a still-debited item. It only matters when the item does enter the money path, and it means the debit must be paid with cash rather than a voucher grant.

The two flags do not replace each other. A normal billable item can have allow_voucher="false" to force cash. A pure analytics item uses analytics_only="true" instead, and allow_voucher is ignored because no debit is produced. See Allow Voucher for the funding source side.

How analytics-only interacts with usage limits

Section titled “How analytics-only interacts with usage limits”

analytics_only does not configure usage limits. Usage limits are still driven by subject routes and usage-control rules through their measure, selector_type, selector_value, and currency. If you do not want a particular analytics-only metric to count toward a limit, simply do not select that metric in the corresponding usage-control rule.

Do not expect analytics_only to act as a per-metric usage-limit escape. The flag is about whether the item is debited, not about whether the limit engine observes it.

The same event already carries input_tokens, output_tokens, and cached_tokens from the provider response. Pairing them with cost prices reported through x-billing.pricing_hints lets you compute the supplier cost of each token type and a rolled-up total cost, without changing the revenue charge items that already debit the customer.

This is the finance side of the same event. The revenue items stay rated and debited as before; the cost items are marked analytics_only so they keep full usage and rating facts for analytics but skip the customer money path.

Step 1: report cost prices via x-billing.pricing_hints

Section titled “Step 1: report cost prices via x-billing.pricing_hints”

Use a cost.<metric> key in pricing_hints for each cost price. The key shape keeps cost prices separate from the existing revenue prices already keyed by the metric name, so both can coexist in the same event.

{
"x-billing": {
"pricing_hints": {
"input_tokens": { "unit_price": 0.75, "pricing_unit": 1000000, "currency": "CREDIT" },
"output_tokens": { "unit_price": 3, "pricing_unit": 1000000, "currency": "CREDIT" },
"cached_tokens": { "unit_price": 0.08, "pricing_unit": 1000000, "currency": "CREDIT" },
"cost.input_tokens": { "unit_price": 0.30, "pricing_unit": 1000000, "currency": "CREDIT" },
"cost.output_tokens": { "unit_price": 1.20, "pricing_unit": 1000000, "currency": "CREDIT" },
"cost.cached_tokens": { "unit_price": 0.05, "pricing_unit": 1000000, "currency": "CREDIT" }
}
}
}

The cost currency does not have to match the revenue currency. A provider cost in USD can sit alongside a customer charge in CREDIT; the cost items remain analytics-only and never enter the wallet.

Step 2: define per-metric cost items in the extractor

Section titled “Step 2: define per-metric cost items in the extractor”

Append three virtual cost items after the revenue items in charge_item_extractors. Each reads the source quantity from the same-event item snapshot and looks up the cost price from pricing_hints.

{
"charge_item_extractors": [
{"name": "input-tokens", "metric": "input_tokens", "path": "$.usage.input_tokens", "type": "number", "unit": "token", "skip_if_missing": true, "currency": "CREDIT", "unit_price_expr": "dec('0')", "amount_expr": "dec_mul(dec_div(dec(item['quantity']), dec(item['pricing_unit'])), dec(item['unit_price']))"},
{"name": "output-tokens", "metric": "output_tokens", "path": "$.usage.output_tokens", "type": "number", "unit": "token", "skip_if_missing": true, "currency": "CREDIT", "unit_price_expr": "dec('0')", "amount_expr": "dec_mul(dec_div(dec(item['quantity']), dec(item['pricing_unit'])), dec(item['unit_price']))"},
{"name": "cached-tokens", "metric": "cached_tokens", "path": "$.usage.input_tokens_details.cached_tokens","type": "number", "unit": "token", "skip_if_missing": true, "currency": "CREDIT", "unit_price_expr": "dec('0')", "amount_expr": "dec_mul(dec_div(dec(item['quantity']), dec(item['pricing_unit'])), dec(item['unit_price']))"},
{
"name": "cost-input-tokens",
"metric": "cost.input_tokens",
"type": "number",
"unit": "token",
"quantity_expr": "dec(items['input_tokens']['quantity'])",
"currency": "CREDIT",
"amount_expr": "dec_mul(dec_div(dec(items['input_tokens']['quantity']), dec(pricing_hints['cost.input_tokens']['pricing_unit'])), dec(pricing_hints['cost.input_tokens']['unit_price']))",
"control": { "analytics_only": "true" }
},
{
"name": "cost-output-tokens",
"metric": "cost.output_tokens",
"type": "number",
"unit": "token",
"quantity_expr": "dec(items['output_tokens']['quantity'])",
"currency": "CREDIT",
"amount_expr": "dec_mul(dec_div(dec(items['output_tokens']['quantity']), dec(pricing_hints['cost.output_tokens']['pricing_unit'])), dec(pricing_hints['cost.output_tokens']['unit_price']))",
"control": { "analytics_only": "true" }
},
{
"name": "cost-cached-tokens",
"metric": "cost.cached_tokens",
"type": "number",
"unit": "token",
"quantity_expr": "dec(items['cached_tokens']['quantity'])",
"currency": "CREDIT",
"amount_expr": "dec_mul(dec_div(dec(items['cached_tokens']['quantity']), dec(pricing_hints['cost.cached_tokens']['pricing_unit'])), dec(pricing_hints['cost.cached_tokens']['unit_price']))",
"control": { "analytics_only": "true" }
}
]
}

For each cost.<metric>, quantity_expr reads the source metric’s quantity through items['<metric>']['quantity'] so the quantity tracks the original token count one-to-one, and amount_expr multiplies that quantity by the supplier price in pricing_hints['cost.<metric>']. Both reads are filled from the same-event item snapshot written by the extractor that ran earlier in the list. The dependency ordering matters here; see How Extraction Runs for the details. pricing_hints is exposed to every expression as a read-only price book, not only to the metric that originally owns the hint, which is what lets the cost items read a key that was not registered against them.

Step 3: roll the per-metric costs into a total

Section titled “Step 3: roll the per-metric costs into a total”

Add a cost.total aggregator that depends on the three per-metric costs and comes after them in the list:

{
"name": "cost-total",
"metric": "cost.total",
"type": "number",
"unit": "event",
"quantity_expr": "dec('0')",
"currency": "CREDIT",
"amount_expr": "dec_add(dec_add(dec(items['cost.input_tokens']['amount']), dec(items['cost.output_tokens']['amount'])), dec(items['cost.cached_tokens']['amount']))",
"control": { "analytics_only": "true" }
}

cost.total aggregates amounts only, so its quantity_expr is dec('0'). All four cost items use analytics_only so they remain queryable for finance dashboards but never reach the customer’s wallet.

After the cost prices, the per-metric cost items, and the total cost aggregator are in place, the gateway sends one complete event to the ingest endpoint. The body is the full envelope: an outer source, identity, subject, and timestamp, the original provider response under raw_json, and the dynamic billing extension under x-billing carrying both revenue prices and cost prices in the same pricing_hints map.

{
"source": "openai-gateway",
"external_event_id": "resp_01JZ8E7K9S3K4N2V9A6Y0B1C2D",
"idempotency_key": "openai-gateway:resp_01JZ8E7K9S3K4N2V9A6Y0B1C2D",
"subject_type": "organization",
"subject_id": "org_001",
"occurred_at": 1783008000,
"raw_json": {
"usage": {
"input_tokens": 2400,
"output_tokens": 520,
"total_tokens": 2920,
"input_tokens_details": {
"cached_tokens": 800
}
}
},
"metadata": {
"provider": "openai",
"model": "gpt-4.1-mini",
"type": "response.completed",
"membership_tier": "pro"
},
"x-billing": {
"pricing_hints": {
"input_tokens": { "unit_price": 0.75, "pricing_unit": 1000000, "currency": "CREDIT" },
"output_tokens": { "unit_price": 3, "pricing_unit": 1000000, "currency": "CREDIT" },
"cached_tokens": { "unit_price": 0.08, "pricing_unit": 1000000, "currency": "CREDIT" },
"cost.input_tokens": { "unit_price": 0.30, "pricing_unit": 1000000, "currency": "CREDIT" },
"cost.output_tokens": { "unit_price": 1.20, "pricing_unit": 1000000, "currency": "CREDIT" },
"cost.cached_tokens": { "unit_price": 0.05, "pricing_unit": 1000000, "currency": "CREDIT" }
}
}
}

POST it to POST /v1/projects/:project_id/extractor-rule-sets/:id/events/ingest. raw_json carries only usage, because that is all the extractor in step 2 reads. The OpenAI response id and the meta.subject_* / meta.project_id / meta.api_key_id fields are dropped: the first duplicates the top-level external_event_id, the others are already pinned by subject_type / subject_id and the request URL. Categorical metadata (provider, model, type, membership_tier) moves to top-level metadata and automatically becomes a dimension on the rated usage event as long as the value is not pure numeric. The numbers in raw_json.usage are the same ones the worked example below uses, so the rated result matches step by step. The full envelope field reference, including how idempotency_key deduplicates retries and how subject_type / subject_id override the extractor’s subject paths, lives on the Usage Events page; the request contract for the endpoint is on Ingest Usage Event.

Suppose the same event has input_tokens: 2400, output_tokens: 520, cached_tokens: 800. With the cost prices in step 1, the runtime produces:

Metric Quantity Amount (CREDIT)
cost.input_tokens 2400 2400 / 1,000,000 × 0.30 = 0.00072
cost.output_tokens 520 520 / 1,000,000 × 1.20 = 0.000624
cost.cached_tokens 800 800 / 1,000,000 × 0.05 = 0.00004
cost.total 0 0.00072 + 0.000624 + 0.00004 = 0.001384

The revenue items for the same event still debit the customer as before. The cost items enter UsageEvent.ChargeItems, the usage event log, and the analytics projection, so POST /v1/projects/:project_id/usage/query with metrics=cost.input_tokens,cost.output_tokens,cost.cached_tokens,cost.total returns them grouped by provider, model, or any other dimension.

If you also want a per-event revenue total alongside the cost total, add a total_tokens.amount aggregator the same way, with the same analytics_only flag. Keep margin as a query-time aggregation rather than another charge item so the audit trail stays inside the four rated items:

gross_margin = (sum(total_tokens.amount) - sum(cost.total.amount)) / sum(total_tokens.amount)

Once the cost items are in place, the same event shows customer revenue, supplier cost, and gross margin side by side in the Financial Analysis page of the Portal. That page is the live example of this pattern.

  • Charge-Item Control — overview of the control block and how keys interact.
  • Allow Voucher — the funding source side of the same block.
  • Create Rules — how to define charge_item_extractors and attach pricing.
  • How Extraction Runs — runtime order and the items[metric] context used by virtual metrics.
  • LLM Gateway — provider normalization, x-billing.pricing_hints, and x-billing.items.