> For the complete documentation index, see [llms.txt](https://docs.khotan.com/llms.txt). A markdown version of any page is available at its `index.md` URL.

# 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](/basics/projects/#metrics).

## 1. Give the app its own key

Mint a key for this app alone, named after it, with the `developer` role:

```bash
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:

```bash
curl https://app.khotan.com/api/v1/projects \
  -H "Authorization: Bearer $KHOTAN_API_KEY"
```

## 3. Report

One request carries every metric and its points:

```bash
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 }
        ]
      }
    ]
  }'
```

```json
{
  "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:

```ts
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 `day` bucket 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 `hour` bucket is an ISO 8601 timestamp with an offset, such as `2026-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.
