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:
spai analytics catalog
spai analytics catalog --dataset usageDatasets
usage: billed consumption
Consumption billed to the organization, always at selling price in the organization currency (see Price is selling price).
| Measure | What it counts |
|---|---|
price_local | Billed amount in local currency. Needs billing:read. |
quantity | Consumed amount in the item unit. Requires a single unit per row (see below). |
billed_quantity | Amount the price was multiplied by, in billed_unit. Requires a single billed unit per row (see Billed quantity and rounding). |
items | Billed item lines. |
requests | Billed requests. One request can use several models, so it cannot be grouped by model. |
ttfb_p50_ms, ttfb_p95_ms | Time 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.
| Measure | What it counts |
|---|---|
conversations | Started threads. |
escalated | Threads with a human escalation. |
escalation_rate | Escalated divided by conversations. |
price_local | Billed amount behind the threads. Needs billing:read. |
price_per_conversation | Billed 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_subtypeagent, pricing rulevoice): 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_unitisminute. - 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, sobilled_quantityequalsquantityandbilled_unitequalsunit(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
{
"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>dwith N from 1 to 400,this_month,last_month, or an explicit{ "from": "YYYY-MM-DD", "to": "YYYY-MM-DD" }. Defaultlast_30d. Windows are whole UTC days, except on the exacttzpath below, where the days are local days intz.tois clamped to today.granularity:none,day,weekormonth. Defaultnone. Theperiodcolumn holds the bucket start date asYYYY-MM-DD(a week starts on Monday, a month on the 1st). Withoutorder, rows come by period ascending and then by the first measure descending.tz: an IANA name, defaultUTC. It changes the bucketing of raw sources and thehour_of_daydimension. Forusagewith a non-UTC zone:- Up to 31 days (a
comparewindow 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 norollup_utc_dayswarning is returned. Raw items serveprice_local,quantity,billed_quantity,items,ttfb_p50_ms,ttfb_p95_ms,project,user,model,charge_subtype,unit,pricing_ruleandbilled_unit. - Longer windows, or a query that uses
agentorrequests: rollups group by UTC day, and a time granularity adds arollup_utc_dayswarning.
- Up to 31 days (a
- At most 6 measures, 3 dimensions and 10 filters per query. Unknown keys are rejected.
- At most 5000 rows; extra rows set
truncated: truewith atruncatedwarning. 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
| Code | Status | When |
|---|---|---|
analytics_invalid_query | 400 | Bad 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_forbidden | 403 | forbidden_measure (a price measure without billing:read) or forbidden_dimension (contact without threads read). |
analytics_query_timeout | 422 | The 5 second cap was hit. Narrow the window or drop dimensions. |
analytics_busy | 429 | More than 2 analytics queries are already running for the organization. Wait and retry. |
analytics_unavailable | 503 | The 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
| Tool | What it does | Scope |
|---|---|---|
analytics_catalog | Lists datasets, measures, dimensions, allowed values and limits for the token | spai:analytics:read |
analytics_query | Runs 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:
This command needs spai:analytics:read. Run: spai auth loginWith an API key there is no local scope check.
Weekly LLM spend by model, rendered as a chart spec:
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.jsonCost per conversation by agent, compared with the previous 30 days:
spai analytics query conversations \
--measure price_per_conversation,conversations \
--dimension agent \
--range last_30d \
--compare previous_periodEscalation by hour of day, to size the human team:
spai analytics query conversations \
--measure escalated,escalation_rate \
--dimension hour_of_day \
--range last_30d \
--tz America/Sao_Paulo \
--order hour_of_day:ascResolution rate and average CSAT by contact reason, sent from a file as CSV:
spai analytics query --file query.json --format csvwith query.json holding the exact query shape:
{
"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
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).
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.
