Reporting metrics
A project's Metrics tab shows whether the thing that was built is working. The numbers come from the apps themselves: once a day, a scheduled job counts what happened and reports it. This page walks through that job. For what each attribute means, see Metrics.
1. Give the app its own key
Mint a key for this app alone, named after it, with the developer role:
curl -X POST https://app.khotan.com/api/v1/api-keys \
-H "Content-Type: application/json" \
-d '{
"email": "you@example.com",
"password": "your-password",
"organizationId": "org_...",
"name": "order-agent-metrics",
"role": "developer"
}'
Store it in the app's deployment environment as KHOTAN_API_KEY, never in the repository.
A developer key can do far more in your organization than report metrics. A dedicated, named key per app keeps that exposure revocable on its own: if an app is retired or its environment is compromised, revoke that one key without touching anything else.
2. Find the project
Reports go to a project by id or slug:
curl https://app.khotan.com/api/v1/projects \
-H "Authorization: Bearer $KHOTAN_API_KEY"
3. Report
One request carries every metric and its points:
curl -X POST https://app.khotan.com/api/v1/projects/order-agent/metrics \
-H "Authorization: Bearer $KHOTAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"metrics": [
{
"key": "orders_processed",
"name": "Orders processed",
"shape": "series",
"grain": "day",
"unit": "count",
"goal": "increase",
"aggregate": "sum",
"points": [{ "bucket": "2026-09-14", "value": 1203 }]
},
{
"key": "exceptions_by_type",
"name": "Exceptions",
"shape": "breakdown",
"grain": "day",
"unit": "count",
"goal": "decrease",
"aggregate": "sum",
"points": [
{ "bucket": "2026-09-14", "category": "Missing PO", "value": 4 },
{ "bucket": "2026-09-14", "category": "Price mismatch", "value": 2 }
]
}
]
}'
{
"metrics": [
{ "key": "orders_processed", "created": true, "pointsWritten": 1 },
{ "key": "exceptions_by_type", "created": true, "pointsWritten": 2 }
],
"metricsWritten": 2,
"pointsWritten": 3
}
The first report creates each metric. Every later report updates its unit, goal, aggregate, description, and default name, and sets its points. A metric's shape and grain never change.
From a cron job
In an app built from the Khotan starter, the scheduled route is app/api/cron/route.ts. Count the day's numbers, then report them:
const KHOTAN_API_URL = "https://app.khotan.com"
export async function GET() {
// The app's own business date, in its own time zone — not the server's UTC date.
const bucket = new Intl.DateTimeFormat("en-CA", {
timeZone: "Australia/Sydney",
}).format(new Date())
const ordersProcessed = await countOrdersProcessed(bucket)
const response = await fetch(
`${KHOTAN_API_URL}/api/v1/projects/order-agent/metrics`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.KHOTAN_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
metrics: [
{
key: "orders_processed",
name: "Orders processed",
shape: "series",
grain: "day",
unit: "count",
goal: "increase",
aggregate: "sum",
points: [{ bucket, value: ordersProcessed }],
},
],
}),
},
)
if (!response.ok) {
// Safe to retry: reporting the same bucket again sets the same value.
throw new Error(`Metrics report failed: ${response.status} ${await response.text()}`)
}
return Response.json({ ok: true })
}
Reporting the running total for today, every run, is the simplest pattern: a run that fails is corrected by the next, and the last run of the day leaves the final number.
Dates and times
- A
daybucket is a calendar date,YYYY-MM-DD, in the app's own business day. Khotan stores it exactly as sent. Format it in the business's time zone, as above — an app in Sydney reporting at 9am must not file its orders under yesterday's UTC date. - An
hourbucket is an ISO 8601 timestamp with an offset, such as2026-09-14T10:00:00+10:00. It is stored as its UTC hour. - Nothing may be more than one day in the future.
Retries
Reports set values; they never add. A series point is identified by its bucket, a breakdown point by its bucket and category, and a scatter point by the id you give it. Retrying a report after a timeout, or sending it twice, leaves exactly what sending it once would. Sending a different value for a bucket replaces it.
If a report names the same point twice, the last occurrence wins.
Errors
| Status | Code | Meaning |
|---|---|---|
422 |
invalid_input |
The report is malformed. details.metricKey and details.pointIndex name the metric and point at fault. Nothing was written. |
409 |
conflict |
A metric already exists with a different shape or grain, or the report would take the project past 100 metrics. details.metricKey names it. Nothing was written. |
403 |
forbidden |
The key's role is member, which can read metrics but not report them. |
404 |
not_found |
No project with that id or slug exists in the key's organization. |
429 |
rate_limited |
More than 60 reports in a minute across the organization. Retry after Retry-After. |
Limits
A report carries up to 50 metrics and 10,000 points. A breakdown holds up to 50 categories per bucket, and a scatter metric keeps its newest 5,000 points.