# Query gateway metrics

> Read current totals for a Gateway, chart requests over time broken down by route, and save your own metric recipe.

This guide reads traffic metrics for a Gateway three ways: a snapshot of current totals, a time series broken down by route, and a metric recipe you define yourself.

## Prerequisites

- An Apoxy project with a Gateway serving traffic.
- An API key from the Apoxy console (**Account Settings > API Keys**).
- Your project ID, shown in the [Apoxy console](https://dashboard.apoxy.dev).

Set both in your shell so the examples below stay short:

```bash title="terminal"
export APOXY_API_KEY="<api-key>"
export APOXY_API="https://<project-id>.api.apoxy.dev"
```

## See what you can query

The catalog lists every metric recipe available in your project:

```bash title="terminal"
curl -s -H "X-Apoxy-API-Key: $APOXY_API_KEY" \
  "$APOXY_API/apis/metrics.apoxy.dev/v1alpha1/metrics"
```

You should see the built-in recipes, including `http.requests`, `http.latency`, `http.bytes`, and `http.errors`. Each entry carries its measures under `status.measures` and the dimensions it can be grouped by under `status.keys`.

## Read a Gateway snapshot

A snapshot returns every applicable recipe for one object over one window in a single call. Ask for the last hour of a Gateway named `prod`, with a per-route breakdown:

```bash title="terminal"
curl -s -H "X-Apoxy-API-Key: $APOXY_API_KEY" \
  "$APOXY_API/apis/gateway.apoxy.dev/v1/gateways/prod/metrics?window=1h&include=routes&top=10"
```

You should see totals for the Gateway, then a `listeners` array, each listener carrying its own totals and the top routes under it:

```json
{
  "kind": "GatewayMetrics",
  "metadata": { "name": "prod" },
  "window": "1h0m0s",
  "since": "2026-08-20T20:00:00Z",
  "until": "2026-08-20T21:00:00Z",
  "dataUpTo": "2026-08-20T21:00:00Z",
  "metrics": {
    "http.requests": { "total": 76821, "status_2xx": 74604, "status_4xx": 2061, "status_5xx": 156 },
    "http.latency": { "p50": 38, "p95": 190, "p99": 412 }
  },
  "units": { "p50": "ms", "p95": "ms", "p99": "ms" },
  "listeners": [
    {
      "name": "https",
      "metrics": { "http.requests": { "total": 76705, "status_5xx": 156 } },
      "truncated": true,
      "totalCount": 37,
      "routes": [
        { "kind": "HTTPRoute", "name": "api", "rule": 0,
          "metrics": { "http.requests": { "total": 62958, "status_5xx": 149 } } }
      ]
    }
  ]
}
```

`truncated: true` with `totalCount: 37` means the listener has 37 routes with traffic and you asked for the top 10. Raise `top` up to 50, or use the series query below for complete coverage.

Omit `include=routes` and you get the Gateway and listener totals only, which is the cheaper read for a status tile.

## Chart requests over time

The series endpoint returns one line per group. Ask for six hours of requests on the same Gateway, in five-minute buckets, split by route and ranked by server errors:

```bash title="terminal"
curl -s -H "X-Apoxy-API-Key: $APOXY_API_KEY" \
  "$APOXY_API/apis/metrics.apoxy.dev/v1alpha1/metrics/http.requests/series?scopeKind=Gateway&scopeName=prod&groupBy=route_name&since=-6h&step=5m&top=10&orderBy=status_5xx"
```

You should see a `series` array, one entry per route, each with a `points` array:

```json
{
  "kind": "MetricSeriesSet",
  "metric": "http.requests",
  "scopeKind": "Gateway",
  "scopeName": "prod",
  "since": "2026-08-20T15:00:00Z",
  "until": "2026-08-20T21:00:00Z",
  "step": "5m0s",
  "dataUpTo": "2026-08-20T21:00:00Z",
  "truncated": false,
  "totalCount": 7,
  "series": [
    {
      "labels": { "route_name": "api" },
      "points": [
        { "timestamp": "2026-08-20T15:00:00Z", "values": { "total": 5210, "status_4xx": 140, "status_5xx": 12 } },
        { "timestamp": "2026-08-20T15:05:00Z", "values": { "total": 5188, "status_4xx": 131, "status_5xx": 9 } }
      ]
    }
  ]
}
```

Buckets with no traffic are omitted rather than returned as zero. Anything after `dataUpTo` is still filling, so plot up to that timestamp to avoid a trailing dip that corrects itself a minute later.

Swap `groupBy=route_name` for `status_class`, `method`, `listener`, or `backend_name` to slice the same recipe a different way. Swap `http.requests` for `http.latency` to chart p50, p95, and p99 instead.

## Get one row per Gateway

Set `step` equal to the window and you get a single bucket, which turns the series endpoint into a fleet listing. Group by `gateway` at project scope for one row per Gateway:

```bash title="terminal"
curl -s -H "X-Apoxy-API-Key: $APOXY_API_KEY" \
  "$APOXY_API/apis/metrics.apoxy.dev/v1alpha1/metrics/http.requests/series?scopeKind=Project&groupBy=gateway&since=-1h&step=1h"
```

Each series comes back with exactly one point. This is the read behind a list page, and it replaces polling every object's snapshot separately.

## Save your own metric

The built-in recipes cover request counts, errors, latency, and bytes. For anything else, save a recipe and query it through the same endpoint. This one counts server errors on a single path prefix:

```yaml title="metric.yaml"
apiVersion: metrics.apoxy.dev/v1alpha1
kind: Metric
metadata:
  name: v2.errors
spec:
  source: otel_logs
  type: counter
  description: Server errors on the /v2 path prefix.
  prql: |
    filter (url.path | text.starts_with "/v2/")
    filter http.response.status_code >= 500
    aggregate { n = count this }
```

Apply it:

```bash title="terminal"
apoxy apply -f metric.yaml
```

The fragment is aggregate-only: `filter` and `derive` steps followed by one `aggregate`. Leave out `from`, `group`, and `time_bucket` entirely. The source comes from `spec.source`, the time bucket from the `step` parameter, and the grouping from `groupBy`, so the recipe stays reusable at any resolution.

The server compiles the fragment as it stores it. A fragment that does not compile is rejected on the spot with the compiler's message, so you find out at apply time rather than at read time.

Query it exactly like a built-in:

```bash title="terminal"
curl -s -H "X-Apoxy-API-Key: $APOXY_API_KEY" \
  "$APOXY_API/apis/metrics.apoxy.dev/v1alpha1/metrics/v2.errors/series?scopeKind=Gateway&scopeName=prod&since=-1h&step=1m&groupBy=envoy.response_flags"
```

<Callout label="Reserved names" variant="warn">
The prefixes `http.`, `log.`, `upstream.`, and `tls.` belong to the built-in recipes, and the name `series` is taken by the subresource. Writing to any of them returns `403`. Pick a prefix of your own, as `v2.errors` does above.
</Callout>

## Verify

Confirm the recipe was stored and compiled:

```bash title="terminal"
curl -s -H "X-Apoxy-API-Key: $APOXY_API_KEY" \
  "$APOXY_API/apis/metrics.apoxy.dev/v1alpha1/metrics/v2.errors"
```

You should see a `Compiled` condition with status `True`, along with a `status.measures` entry for `n` and the list of keys you can group it by.

## Common errors

- **`400` with a list of valid keys** - the `groupBy` field is not one of the recipe's `status.keys`. Read the recipe to see what it supports.
- **`400` naming buckets, series, and measures** - the request asks for more than 20000 points. Widen `step`, lower `top`, or restrict recipes with `metric=`.
- **`403` on apply** - the recipe name uses a reserved prefix.
- **`404`** - the Gateway, route, or other owner object in the path does not exist.
- **`429`** - too many concurrent queries. Retry after the interval in the `Retry-After` header.

## Where to next

- Full parameter, response, and limit reference in the [Metrics API](/docs/reference/metrics.md).
- Free-form questions over raw log records with [PRQL and the MCP server](/docs/reference/mcp.md).

---

**Navigation** (Guides)

- Previous: [Rotating the kube-controller certificate](/docs/guides/rotating-kube-controller-cert.md)
- Next: [Query logs with Claude Code](/docs/guides/mcp-claude-code.md)
- All pages: [index](/docs/llms.txt)
