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

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.