Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/docs/analytics/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ Each object in the `data` array contains fields from your SELECT clause. The fie

You can filter queries to specific APIs or users. Use `key_space_id` to filter by API (find this identifier in your API settings) and `external_id` to filter by user. These fields support standard SQL operators: `=`, `!=`, `IN`, `NOT IN`, `<`, `>`, etc.

<Note>
**Automatic filtering:** All queries are automatically filtered based on your root key's permissions:

- **Workspace filtering:** All queries are scoped to your workspace. You **do not need** to filter by `workspace_id`.
- **API filtering:** If your root key has `api.<api_id>.read_analytics` permissions (scoped to a specific API), queries are automatically filtered to that API's `key_space_id`. If your root key has `api.*.read_analytics` (all APIs), you should filter by `key_space_id` yourself to query specific APIs.
</Note>

<Note>
Queries are subject to resource limits (execution time, memory, result size,
and quota). See [Query Restrictions](/analytics/query-restrictions) for
Expand Down
28 changes: 16 additions & 12 deletions apps/docs/analytics/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ Unkey Analytics provides a powerful SQL interface to query your API key verifica
- **Generate reports** on API usage patterns, top users, and performance metrics
- **Monitor and alert** on verification outcomes, rate limits, and errors

<Note>
**Automatic filtering:** All queries are scoped to your workspace automatically. If your root key is scoped to a specific API (`api.<api_id>.read_analytics`), queries are also filtered to that API's `key_space_id`.
</Note>

## How it Works

Every key verification request is automatically stored and aggregated across multiple time-series tables:
Expand All @@ -43,18 +47,18 @@ You can query these tables using standard SQL to:

Every verification event contains:

| Field | Type | Description |
| --------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `request_id` | String | Unique identifier for each request |
| `time` | Int64 | Unix millisecond timestamp |
| `workspace_id` | String | Your workspace identifier (automatically filtered) |
| `key_space_id` | String | Your KeySpace identifier (e.g., `ks_1234`). Find this in your API settings. |
| `external_id` | String | Your user's identifier (e.g., `user_abc`) |
| `key_id` | String | Individual key identifier |
| `outcome` | String | Verification result: `VALID`, `RATE_LIMITED`, `INVALID`, `EXPIRED`, `DISABLED`, `INSUFFICIENT_PERMISSIONS`, `FORBIDDEN`, `USAGE_EXCEEDED` |
| `region` | String | Unkey region that handled the verification |
| `tags` | Array(String) | Custom tags added during verification |
| `spent_credits` | Int64 | Number of credits spent on this verification (0 if no credits were spent) |
| Field | Type | Description |
| --------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `request_id` | String | Unique identifier for each request |
| `time` | Int64 | Unix millisecond timestamp |
| `workspace_id` | String | Your workspace identifier (**automatically filtered** - you don't need to filter by this) |
| `key_space_id` | String | Your KeySpace identifier (e.g., `ks_1234`). **Automatically filtered** if your root key is scoped to a single API. |
| `external_id` | String | Your user's identifier (e.g., `user_abc`) |
| `key_id` | String | Individual key identifier |
| `outcome` | String | Verification result: `VALID`, `RATE_LIMITED`, `INVALID`, `EXPIRED`, `DISABLED`, `INSUFFICIENT_PERMISSIONS`, `FORBIDDEN`, `USAGE_EXCEEDED` |
| `region` | String | Unkey region that handled the verification |
| `tags` | Array(String) | Custom tags added during verification |
| `spent_credits` | Int64 | Number of credits spent on this verification (0 if no credits were spent) |

## Use Cases

Expand Down
222 changes: 222 additions & 0 deletions apps/docs/analytics/query-examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,76 @@ curl -X POST https://api.unkey.com/v2/analytics.getVerifications \

</CodeGroup>

### All outcomes in a single row

Get all verification outcomes in one row with individual columns for each outcome type.

<CodeGroup>
```sql SQL
SELECT
sumIf(count, outcome = 'VALID') AS valid,
sumIf(count, outcome = 'RATE_LIMITED') AS rateLimited,
sumIf(count, outcome = 'INVALID') AS invalid,
sumIf(count, outcome = 'NOT_FOUND') AS notFound,
sumIf(count, outcome = 'FORBIDDEN') AS forbidden,
sumIf(count, outcome = 'USAGE_EXCEEDED') AS usageExceeded,
sumIf(count, outcome = 'UNAUTHORIZED') AS unauthorized,
sumIf(count, outcome = 'DISABLED') AS disabled,
sumIf(count, outcome = 'INSUFFICIENT_PERMISSIONS') AS insufficientPermissions,
sumIf(count, outcome = 'EXPIRED') AS expired,
SUM(count) AS total
FROM key_verifications_per_day_v1
WHERE time >= now() - INTERVAL 30 DAY
```

```bash cURL
curl -X POST https://api.unkey.com/v2/analytics.getVerifications \
-H "Authorization: Bearer <YOUR_ROOT_KEY>" \
-H "Content-Type: application/json" \
-d '{
"query": "SELECT sumIf(count, outcome = '\''VALID'\'') AS valid, sumIf(count, outcome = '\''RATE_LIMITED'\'') AS rateLimited, sumIf(count, outcome = '\''INVALID'\'') AS invalid, sumIf(count, outcome = '\''NOT_FOUND'\'') AS notFound, sumIf(count, outcome = '\''FORBIDDEN'\'') AS forbidden, sumIf(count, outcome = '\''USAGE_EXCEEDED'\'') AS usageExceeded, sumIf(count, outcome = '\''UNAUTHORIZED'\'') AS unauthorized, sumIf(count, outcome = '\''DISABLED'\'') AS disabled, sumIf(count, outcome = '\''INSUFFICIENT_PERMISSIONS'\'') AS insufficientPermissions, sumIf(count, outcome = '\''EXPIRED'\'') AS expired, SUM(count) AS total FROM key_verifications_per_day_v1 WHERE time >= now() - INTERVAL 30 DAY"
}'
```

</CodeGroup>

### All outcomes per key

Get outcome breakdown for each API key in a single row per key.

<CodeGroup>
```sql SQL
SELECT
key_id,
sumIf(count, outcome = 'VALID') AS valid,
sumIf(count, outcome = 'RATE_LIMITED') AS rateLimited,
sumIf(count, outcome = 'INVALID') AS invalid,
sumIf(count, outcome = 'NOT_FOUND') AS notFound,
sumIf(count, outcome = 'FORBIDDEN') AS forbidden,
sumIf(count, outcome = 'USAGE_EXCEEDED') AS usageExceeded,
sumIf(count, outcome = 'UNAUTHORIZED') AS unauthorized,
sumIf(count, outcome = 'DISABLED') AS disabled,
sumIf(count, outcome = 'INSUFFICIENT_PERMISSIONS') AS insufficientPermissions,
sumIf(count, outcome = 'EXPIRED') AS expired,
SUM(count) AS total
FROM key_verifications_per_day_v1
WHERE time >= now() - INTERVAL 30 DAY
GROUP BY key_id
ORDER BY total DESC
LIMIT 100
```

```bash cURL
curl -X POST https://api.unkey.com/v2/analytics.getVerifications \
-H "Authorization: Bearer <YOUR_ROOT_KEY>" \
-H "Content-Type: application/json" \
-d '{
"query": "SELECT key_id, sumIf(count, outcome = '\''VALID'\'') AS valid, sumIf(count, outcome = '\''RATE_LIMITED'\'') AS rateLimited, sumIf(count, outcome = '\''INVALID'\'') AS invalid, sumIf(count, outcome = '\''NOT_FOUND'\'') AS notFound, sumIf(count, outcome = '\''FORBIDDEN'\'') AS forbidden, sumIf(count, outcome = '\''USAGE_EXCEEDED'\'') AS usageExceeded, sumIf(count, outcome = '\''UNAUTHORIZED'\'') AS unauthorized, sumIf(count, outcome = '\''DISABLED'\'') AS disabled, sumIf(count, outcome = '\''INSUFFICIENT_PERMISSIONS'\'') AS insufficientPermissions, sumIf(count, outcome = '\''EXPIRED'\'') AS expired, SUM(count) AS total FROM key_verifications_per_day_v1 WHERE time >= now() - INTERVAL 30 DAY GROUP BY key_id ORDER BY total DESC LIMIT 100"
}'
```

</CodeGroup>

### Daily verification trend

Track daily verification patterns over the last 30 days.
Expand Down Expand Up @@ -847,6 +917,158 @@ curl -X POST https://api.unkey.com/v2/analytics.getVerifications \

</CodeGroup>

## Filling Gaps in Time Series (WITH FILL)

When querying time series data, you may have periods with no activity that result in missing time points. ClickHouse's `WITH FILL` clause ensures all time periods are included in results, filling gaps with zeros.

<Note>
`WITH FILL` is particularly useful for creating charts and visualizations where you need consistent time intervals, even when there's no data for some periods.
</Note>

<Warning>
`WITH FILL` only works when grouping by the time column alone. To include outcome breakdowns or other dimensions, use `sumIf()` to pivot them into columns (see the last example below).
</Warning>

<Note>
**Type matching:** The `time` column type varies by table:
- **Hourly/Minute tables**: `DateTime` - use `toStartOfHour(now() - INTERVAL N HOUR)`
- **Daily/Monthly tables**: `Date` - use `toDate(now() - INTERVAL N DAY)` or `toDate(toStartOfMonth(...))`

WITH FILL expressions must match the column type exactly.
</Note>

### Hourly data with gaps filled

Get hourly verification counts for the last 7 days, including hours with zero activity.

<CodeGroup>
```sql SQL
SELECT
time,
SUM(count) as total
FROM key_verifications_per_hour_v1
WHERE time >= toStartOfHour(now() - INTERVAL 7 DAY)
AND time <= toStartOfHour(now())
GROUP BY time
ORDER BY time ASC
WITH FILL
FROM toStartOfHour(now() - INTERVAL 7 DAY)
TO toStartOfHour(now())
STEP INTERVAL 1 HOUR
```

```bash cURL
curl -X POST https://api.unkey.com/v2/analytics.getVerifications \
-H "Authorization: Bearer <YOUR_ROOT_KEY>" \
-H "Content-Type: application/json" \
-d '{
"query": "SELECT time, SUM(count) as total FROM key_verifications_per_hour_v1 WHERE time >= toStartOfHour(now() - INTERVAL 7 DAY) AND time <= toStartOfHour(now()) GROUP BY time ORDER BY time ASC WITH FILL FROM toStartOfHour(now() - INTERVAL 7 DAY) TO toStartOfHour(now()) STEP INTERVAL 1 HOUR"
}'
```

</CodeGroup>

### Daily data with gaps filled

Get daily verification counts for the last 30 days, ensuring all days are present.

<CodeGroup>
```sql SQL
SELECT
time,
SUM(count) as total
FROM key_verifications_per_day_v1
WHERE time >= toDate(now() - INTERVAL 30 DAY)
AND time <= toDate(now())
GROUP BY time
ORDER BY time ASC
WITH FILL
FROM toDate(now() - INTERVAL 30 DAY)
TO toDate(now())
STEP INTERVAL 1 DAY
```

```bash cURL
curl -X POST https://api.unkey.com/v2/analytics.getVerifications \
-H "Authorization: Bearer <YOUR_ROOT_KEY>" \
-H "Content-Type: application/json" \
-d '{
"query": "SELECT time, SUM(count) as total FROM key_verifications_per_day_v1 WHERE time >= toDate(now() - INTERVAL 30 DAY) AND time <= toDate(now()) GROUP BY time ORDER BY time ASC WITH FILL FROM toDate(now() - INTERVAL 30 DAY) TO toDate(now()) STEP INTERVAL 1 DAY"
}'
```

</CodeGroup>

### Monthly data with gaps filled

Get monthly verification counts for the last 12 months with all months included.

<CodeGroup>
```sql SQL
SELECT
time,
SUM(count) as total
FROM key_verifications_per_month_v1
WHERE time >= toDate(toStartOfMonth(now() - INTERVAL 12 MONTH))
AND time <= toDate(toStartOfMonth(now()))
GROUP BY time
ORDER BY time ASC
WITH FILL
FROM toDate(toStartOfMonth(now() - INTERVAL 12 MONTH))
TO toDate(toStartOfMonth(now()))
STEP INTERVAL 1 MONTH
```

```bash cURL
curl -X POST https://api.unkey.com/v2/analytics.getVerifications \
-H "Authorization: Bearer <YOUR_ROOT_KEY>" \
-H "Content-Type: application/json" \
-d '{
"query": "SELECT time, SUM(count) as total FROM key_verifications_per_month_v1 WHERE time >= toDate(toStartOfMonth(now() - INTERVAL 12 MONTH)) AND time <= toDate(toStartOfMonth(now())) GROUP BY time ORDER BY time ASC WITH FILL FROM toDate(toStartOfMonth(now() - INTERVAL 12 MONTH)) TO toDate(toStartOfMonth(now())) STEP INTERVAL 1 MONTH"
}'
```

</CodeGroup>

### Filling gaps with aggregations

For more complex queries that aggregate by outcome, use a subquery or pivot approach instead of WITH FILL with multiple GROUP BY columns.

<CodeGroup>
```sql SQL
-- Pivot outcomes into columns with all days filled
SELECT
time,
sumIf(count, outcome = 'VALID') as valid,
sumIf(count, outcome = 'RATE_LIMITED') as rate_limited,
sumIf(count, outcome = 'INVALID') as invalid,
SUM(count) as total
FROM key_verifications_per_day_v1
WHERE time >= toDate(now() - INTERVAL 30 DAY)
AND time <= toDate(now())
GROUP BY time
ORDER BY time ASC
WITH FILL
FROM toDate(now() - INTERVAL 30 DAY)
TO toDate(now())
STEP INTERVAL 1 DAY
```

```bash cURL
curl -X POST https://api.unkey.com/v2/analytics.getVerifications \
-H "Authorization: Bearer <YOUR_ROOT_KEY>" \
-H "Content-Type: application/json" \
-d '{
"query": "SELECT time, sumIf(count, outcome = '\''VALID'\'') as valid, sumIf(count, outcome = '\''RATE_LIMITED'\'') as rate_limited, sumIf(count, outcome = '\''INVALID'\'') as invalid, SUM(count) as total FROM key_verifications_per_day_v1 WHERE time >= toDate(now() - INTERVAL 30 DAY) AND time <= toDate(now()) GROUP BY time ORDER BY time ASC WITH FILL FROM toDate(now() - INTERVAL 30 DAY) TO toDate(now()) STEP INTERVAL 1 DAY"
}'
```

</CodeGroup>

<Note>
`WITH FILL` only works when grouping by time alone. For outcome breakdowns, use `sumIf()` to pivot outcomes into separate columns as shown above.
</Note>

## Tips for Efficient Queries

1. **Always filter by time** - Use indexes by including time filters
Expand Down
29 changes: 29 additions & 0 deletions apps/docs/analytics/quick-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,28 @@ GROUP BY endpoint
ORDER BY requests DESC
```

### Filling Gaps in Time Series

**Use for**: Charts and visualizations that need consistent time intervals

```sql
-- Daily data with all days included (even zero counts)
SELECT time, SUM(count) as total
FROM key_verifications_per_day_v1
WHERE time >= toDate(now() - INTERVAL 30 DAY)
AND time <= toDate(now())
GROUP BY time
ORDER BY time ASC
WITH FILL
FROM toDate(now() - INTERVAL 30 DAY)
TO toDate(now())
STEP INTERVAL 1 DAY
```

<Tip>
See [Query Examples - WITH FILL](/analytics/query-examples#filling-gaps-in-time-series-with-fill) for hourly, daily, and monthly examples with outcome breakdowns.
</Tip>

## Table Selection Guide

Choose the right table based on your time range:
Expand All @@ -152,6 +174,13 @@ Choose the right table based on your time range:

## Common Filters

<Note>
**Automatic filtering:** All queries are automatically filtered based on your root key permissions:

- **Workspace:** All queries are scoped to your workspace (no need to filter `workspace_id`)
- **API:** If your root key is scoped to a specific API (`api.<api_id>.read_analytics`), queries are filtered to that API's `key_space_id`. With `api.*.read_analytics` permissions, filter by `key_space_id` yourself.
</Note>

### Time Ranges

```sql
Expand Down
Loading
Loading