Metrics
Messages per day, first response time, work per person, AI resolution, sources, tags and cost: the sendo.metrics API, the Metrics screen and the SQL behind them.
Sendo measures the inbox from the rows it already stores; there is nothing to collect or schedule. Five SQL views (schema v3) define every number, sendo.metrics.* reads them with role checks, and the metrics registry item draws the screen supervisors and admins use.
The API#
Every method takes { actor, from, to, account?, tagIds? }:
actorcomes fromsendo.actor(userId, tenantId). The tenant is always the actor's.from/toare a half-open range of instants. Build them from calendar days withmetricsDayRange({ from, to, timeZone }): both days are included and the bounds are local midnights in that timezone.accountnarrows to one number (account id or Meta phone number id of the tenant; disconnected numbers keep their history). Another tenant's number isACCOUNT_NOT_FOUND.tagIdsis any-match on the conversation's current tags (the tag cut).
// Supervisors and admins (`metrics.view`); agents get FORBIDDEN.
const actor: Actor = await sendo.actor(userId, tenantId);
// October, both days included, in the number's timezone (local midnights, DST-safe).
const range = metricsDayRange({
from: "2026-10-01",
to: "2026-10-31",
timeZone: "America/Guatemala",
});
const daily = await sendo.metrics.daily({ actor, ...range }); // per day and number
const people = await sendo.metrics.byAssignee({ actor, ...range }); // names from resolveUsers
const firstResponse = await sendo.metrics.firstResponse({
actor,
...range,
account: phoneNumberId,
});
const resolved = await sendo.metrics.aiResolution({ actor, ...range, tagIds: [ventasTagId] });
const sources = await sendo.metrics.bySource({ actor, ...range });
// Admins only (`costs.view`): supervisors get FORBIDDEN here and `costCents: null` in `daily`.
const cost = await sendo.metrics.cost({ actor, ...range });| Method | Returns | Needs |
|---|---|---|
daily | Per day and number: inbound, outbound, conversationsStarted, conversationsClosed, costCents, tokens | metrics.view |
byAssignee | Per person and day: conversations, messagesSent, firstResponses, firstResponseSeconds[], median, name | metrics.view |
firstResponse | Per conversation whose first inbound message is in range: who answered first and after how many seconds | metrics.view |
aiResolution | Per conversation closed in range: everHuman, humanMessages, agentMessages, aiResolved | metrics.view |
bySource | New conversations per source and day (source: null = direct) | metrics.view |
cost | Model cost per AI agent and day: costCents (US cents), tokensIn, tokensOut, turns | costs.view |
Supervisors and admins have metrics.view; agents get FORBIDDEN. Only admins have costs.view: for everyone else cost is FORBIDDEN and daily returns costCents, tokensIn and tokensOut as null. byAssignee adds name and avatarUrl from your resolveUsers (null when it isn't configured or doesn't know the id; a failing resolveUsers is logged and the names stay null). Phone replies from the WhatsApp Business app appear as business_app.
Medians are exact across days: merge the firstResponseSeconds arrays of the days you want and take the middle value (the mean of the two middle values for an even count), as the screen does.
Definitions#
- Days are calendar days in each number's timezone (
sendo_accounts.timezone). A daily row counts when its local midnight is inside[from, to). With numbers in several timezones, read one number at a time for exact days. - Messages exclude internal notes, system lines, drafts and imported history. Inbound counts everything the contact sent; outbound counts what the agent, a person or the phone sent to the contact.
- First response is the first agent or human reply (not a note, not a draft) after the conversation's first inbound message.
- Resolved by the AI is a closed conversation that was never handed to a person (
ever_human = false) and has no human message. Its close time is the last close system line, else the last activity. - Source is
conversation.source([#code]from a link,ad:<id>from a Meta ad), counted when the conversation starts. - Cost is the stored
cost_centsof the agent's messages, drafts included (they cost the same to generate).
The Metrics screen#
npx shadcn add @sendo/metricsThe item adds app/(protected)/inbox/metrics with a date range (7, 30, 90 days or custom), number and tag filters, four KPI tiles (messages, new conversations, median first response, % resolved by the AI), a daily volume chart with a table view, tables per person, per source and per tag, and, for admins, cost per day and per agent. Implement requireUser() in app/(protected)/inbox/metrics/metrics-config.ts (it throws until you do) and configure resolveRole; agents see "You don't have access to Metrics".
The screen renders in English or Spanish: the page passes locale={sendoLocale} from lib/sendo-ui.ts, and numbers, costs and days follow the optional formatLocale. Override single texts with the strings prop or edit components/sendo/metrics/strings.ts; see Languages.
The chart is plain HTML and CSS (no chart library), with hover and keyboard tooltips and a table view; see components/sendo/metrics/AGENTS.md for the colours and how to change them.
SQL for Drizzle and Prisma#
Supabase apps get the five views from the schema migration v3 (sendo_metrics_daily, sendo_metrics_by_assignee, sendo_metrics_first_response, sendo_metrics_ai_resolution, sendo_metrics_by_source, all security_invoker = true so RLS applies). For any other Postgres client, @quami/sendo exports the same queries over the base tables as parameterized strings, so the numbers match the views exactly:
METRICS_SQL.daily,.byAssignee,.firstResponse,.aiResolution,.bySource,.costmetricsSqlParams({ tenantId, from, to, accountId?, tagIds? })→[$1 tenant, $2 from, $3 to, $4 account | null, $5 tag ids | null]METRICS_VIEWS: the select body of each view, if you'd rather create the views yourself
// Any Postgres client that takes `$1…$5` parameters. Every query returns view rows (snake_case,
// one row per day × number × tag set): sum them per day yourself, or use `sendo.metrics.*`.
interface PgClient {
query(text: string, params: unknown[]): Promise<{ rows: Record<string, unknown>[] }>;
}
declare const pool: PgClient; // node-postgres Pool, or Drizzle's `db.$client`
const params = metricsSqlParams({ tenantId, ...range, tagIds: [ventasTagId] });
const { rows: dailyRows } = await pool.query(METRICS_SQL.daily, params);interface PrismaLike {
$queryRawUnsafe<T = unknown>(query: string, ...values: unknown[]): Promise<T>;
}
declare const prisma: PrismaLike;
const sourceRows = await prisma.$queryRawUnsafe<Record<string, unknown>[]>(
METRICS_SQL.bySource,
...metricsSqlParams({ tenantId, ...range }),
);// The select bodies of the five `sendo_metrics_*` views (as the v3 migration creates them).
const ddl = Object.entries(METRICS_VIEWS)
.map(
([name, body]) =>
`create or replace view public.${name}\nwith (security_invoker = true) as\n${body};`,
)
.join("\n\n");The queries return view rows in snake_case, one per day, number and tag set (so tag cuts stay exact). Sum them per day, or let the Drizzle and Prisma adapters do it: they run these queries behind sendo.metrics.*, so the API and the screen work the same on every adapter.