Skip to content

Usage Analytics ​

Usage Analytics is a catalog plus a declarative query for agents and scripts. The caller names a dataset, measures, dimensions, a time window and filters, all keys of a server-side catalog. The server resolves the caller scope, picks the table whose grain fits, and returns a compact table. There is no SQL input of any kind: unknown keys are rejected before anything touches the database.

Start with the catalog, then query. The catalog shows only what the current credential may see:

sh
spai analytics catalog
spai analytics catalog --dataset usage

Datasets ​

usage: billed consumption ​

Consumption billed to the organization, always at selling price in the organization currency (see Price is selling price).

MeasureWhat it counts
price_localBilled amount in local currency. Needs billing:read.
quantityConsumed amount in the item unit. Requires a single unit per row (see below).
billed_quantityAmount the price was multiplied by, in billed_unit. Requires a single billed unit per row (see Billed quantity and rounding).
itemsBilled item lines.
requestsBilled requests. One request can use several models, so it cannot be grouped by model.
ttfb_p50_ms, ttfb_p95_msTime to first byte, median and p95. Raw data only, 31 days max.

Dimensions: project, user, agent, model, charge_subtype, unit, pricing_rule, billed_unit. billed_unit exists only on raw item data, so a query that uses it is limited to 31 days.

Sources are picked automatically. Daily rollups cover up to 400 days; the raw item table covers 31 days and backs latency measures. Closed days come from the rollup and today comes from the transactional tables with the same aggregation.

quantity mixes units (tokens, characters, seconds), so each row must resolve to one unit: add unit or charge_subtype to the dimensions, or filter them to a single unit. Otherwise the query is rejected with mixed_units and a ready fix. billed_quantity follows the same rule with billed_unit, unit or charge_subtype.

charge_subtype takes the usage types stored on each item, such as agent, speech-to-text and text-generation. The catalog lists the values the token can see, and pricing_rule takes the pricing rules of the catalog (for example voice). Legacy names such as input_token are still accepted as filters.

conversations: threads by agent, channel and contact ​

Threads (deleted threads excluded) with escalation to a human and billed cost.

MeasureWhat it counts
conversationsStarted threads.
escalatedThreads with a human escalation.
escalation_rateEscalated divided by conversations.
price_localBilled amount behind the threads. Needs billing:read.
price_per_conversationBilled amount divided by conversations. Needs billing:read.

Dimensions: project, user, agent, channel, hour_of_day, contact.

The window covers up to 92 days. hour_of_day follows the requested time zone; contact is masked unless the caller may read threads (see below).

analysis: post-conversation analysis fields ​

Fields of a structured analysis become catalog entries typed by their JSON schema: an enum string becomes a dimension, a boolean becomes a dimension plus a .rate measure (share of true answers), and a number becomes .avg, .min, .max and .count measures. Free text, arrays and objects are never exposed. A query must reference exactly one structured analysis id across its analysis.* dimensions, measures and filters.

Dimensions: project, agent, channel, plus one entry per exposed field, named analysis.<structured_analysis_id>.<field>. The window covers up to 183 days.

Billed quantity and rounding ​

billed_quantity is the amount the price was multiplied by, so it can differ from the metered quantity. Rounding depends on the product:

  • Voice agent (charge_subtype agent, pricing rule voice): billed per session in 60-second blocks, rounded up, with a minimum of 1 second. A 1-second session bills 1 minute, 61 seconds bills 2 minutes and 95 seconds bills 2 minutes. billed_unit is minute.
  • Other products: follow the pricing rule of the model or service used. A rule billed in blocks reports the blocks priced (for example, a transcription model billed per started minute reports minute); any other rule reports the amount priced, so billed_quantity equals quantity and billed_unit equals unit (token, character, second, ...).

Always read billed_unit next to billed_quantity: it is the unit the price was applied to.

Usage recorded before billed quantities existed is not backfilled, so periods before that change show a lower or empty billed_quantity. Use quantity for those periods.

Time windows and limits ​

json
{
  "dataset": "usage",
  "measures": ["price_local"],
  "dimensions": ["model"],
  "time": { "range": "last_56d", "granularity": "week", "tz": "America/Sao_Paulo" },
  "filters": [{ "dim": "charge_subtype", "op": "in", "values": ["input_token", "output_token"] }],
  "limit": 200
}
  • range: last_<N>d with N from 1 to 400, this_month, last_month, or an explicit { "from": "YYYY-MM-DD", "to": "YYYY-MM-DD" }. Default last_30d. Windows are whole UTC days, except on the exact tz path below, where the days are local days in tz. to is clamped to today.
  • granularity: none, day, week or month. Default none. The period column holds the bucket start date as YYYY-MM-DD (a week starts on Monday, a month on the 1st). Without order, rows come by period ascending and then by the first measure descending.
  • tz: an IANA name, default UTC. It changes the bucketing of raw sources and the hour_of_day dimension. For usage with a non-UTC zone:
    • Up to 31 days (a compare window counts twice), with every selected measure, dimension and filter served by raw items: day and month boundaries are computed in that zone, DST-aware at each boundary, and no rollup_utc_days warning is returned. Raw items serve price_local, quantity, billed_quantity, items, ttfb_p50_ms, ttfb_p95_ms, project, user, model, charge_subtype, unit, pricing_rule and billed_unit.
    • Longer windows, or a query that uses agent or requests: rollups group by UTC day, and a time granularity adds a rollup_utc_days warning.
  • At most 6 measures, 3 dimensions and 10 filters per query. Unknown keys are rejected.
  • At most 5000 rows; extra rows set truncated: true with a truncated warning. Default limit is 200.
  • Every query runs under a 5 second cap.
  • Maximum window per selection: 400 days on rollups, 92 days on conversations, 183 days on analysis, 31 days on raw item data.

compare: "previous_period" reruns the same query on the previous window and joins the rows by dimension. It works only with granularity: "none". For a time series, widen the window instead of comparing.

Entity dimensions (project, agent, user) group by id and emit two columns: <dim> with the display name and <dim>_id with the id.

Responses carry meta.warnings with codes such as partial_last_period (the window ends today and the last bucket is incomplete), rollup_utc_days, contact_masked and truncated.

Contact masking ​

contact groups by the full identifier but prints it masked unless the caller has threads read access (for OAuth, the spai:threads:read scope as well). Filtering by contact without that access is rejected with forbidden_dimension instead of returning masked rows.

Price is selling price ​

price_local is always the selling price in the organization currency. Cost is never available: no measure, dimension or filter exposes cost, margin or rate tables. Asking for a cost-like measure answers unknown_measure and points to price_local; it never confirms whether such a column exists.

Errors ​

CodeStatusWhen
analytics_invalid_query400Bad shape or unknown key. details.reason names the cause: unknown_dataset, unknown_measure, unknown_dimension, invalid_filter, invalid_filter_value, invalid_granularity, invalid_order, invalid_limit, too_many_measures, too_many_dimensions, incompatible_selection, mixed_units, range_too_large or invalid_query. details also carries available, allowed or fix where useful.
analytics_forbidden403forbidden_measure (a price measure without billing:read) or forbidden_dimension (contact without threads read).
analytics_query_timeout422The 5 second cap was hit. Narrow the window or drop dimensions.
analytics_busy429More than 2 analytics queries are already running for the organization. Wait and retry.
analytics_unavailable503The analytics datasource is not configured.

A value outside the caller scope (a project, agent, user or contact id or name from another project or organization, or one that does not exist) always answers invalid_filter_value with allowed listing the caller-scoped values, so out-of-scope entities are indistinguishable from nonexistent ones.

OAuth scope ​

spai:analytics:read is a read-only scope for the API and MCP resources. It maps to dashboard:read, never bills, and is included in the CLI default login and read-only login and in the announced MCP scopes. Existing grants do not gain it on refresh: log in again to authorize it. See Authenticate with OAuth.

MCP tools ​

ToolWhat it doesScope
analytics_catalogLists datasets, measures, dimensions, allowed values and limits for the tokenspai:analytics:read
analytics_queryRuns one declarative query. Never accepts SQL.spai:analytics:read

Both tools are read-only. A failed query returns a tool error with the structured code, message and details (for example analytics_invalid_query with reason and fix), so the agent can correct itself instead of guessing.

CLI examples ​

Analytics commands need spai:analytics:read. A session without it fails before any request with the fix:

text
This command needs spai:analytics:read. Run: spai auth login

With an API key there is no local scope check.

Weekly LLM spend by model, rendered as a chart spec:

sh
spai analytics query usage \
  --measure price_local \
  --dimension model \
  --range last_56d \
  --granularity week \
  --tz America/Sao_Paulo \
  --filter 'charge_subtype in input_token,output_token' \
  --vega > spend.vl.json

Cost per conversation by agent, compared with the previous 30 days:

sh
spai analytics query conversations \
  --measure price_per_conversation,conversations \
  --dimension agent \
  --range last_30d \
  --compare previous_period

Escalation by hour of day, to size the human team:

sh
spai analytics query conversations \
  --measure escalated,escalation_rate \
  --dimension hour_of_day \
  --range last_30d \
  --tz America/Sao_Paulo \
  --order hour_of_day:asc

Resolution rate and average CSAT by contact reason, sent from a file as CSV:

sh
spai analytics query --file query.json --format csv

with query.json holding the exact query shape:

json
{
  "dataset": "analysis",
  "measures": ["analysis.support_review.resolved.rate", "analysis.support_review.csat.avg", "conversations"],
  "dimensions": ["analysis.support_review.reason"],
  "time": { "range": "last_30d" },
  "order": [{ "by": "conversations", "dir": "desc" }]
}

support_review is a placeholder for a structured analysis id from the catalog: call GET /v1/analytics/catalog (or spai analytics catalog --dataset analysis) to list the ids visible to the token, then use exactly one id across dimensions, measures and filters.

--file is mutually exclusive with the query-building flags. In pipes the default format is JSON; --format table|json|csv selects the output, and --vega prints a Vega-Lite v5 spec with the data inline. Usage errors exit with code 2 and server errors print code, message and details on stderr.

REST example ​

bash
curl -X POST "https://api.sippulse.ai/v1/analytics/query" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "dataset": "conversations",
    "measures": ["escalation_rate", "conversations"],
    "dimensions": ["channel"],
    "time": { "range": "last_30d" },
    "order": [{ "by": "escalation_rate", "dir": "desc" }]
  }'

Call GET /v1/analytics/catalog first to list the datasets, measures, dimensions and limits visible to the token. Responses use Cache-Control: no-store.

Billed minutes per project for a month ​

Agent minutes and transcription minutes per project for September 2026, in São Paulo time. Each row holds one unit, so the query groups by billed_unit: agent rows come in minute, and transcription rows come in the unit of the transcription model's pricing rule (minute for per-minute rules, second for per-second ones).

bash
curl -X POST "https://api.sippulse.ai/v1/analytics/query" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "dataset": "usage",
    "measures": ["billed_quantity"],
    "dimensions": ["project", "charge_subtype", "billed_unit"],
    "filters": [{ "dim": "charge_subtype", "op": "in", "values": ["agent", "speech-to-text"] }],
    "time": { "range": { "from": "2026-09-01", "to": "2026-09-30" }, "tz": "America/Sao_Paulo" },
    "order": [{ "by": "billed_quantity", "dir": "desc" }]
  }'

The window is 30 days, so the query reads raw items with day boundaries in America/Sao_Paulo and returns no rollup_utc_days warning.