# Paths and table size

> How identifiers become :id, where paths are cut, and the allow-list that is the only hard bound.

Source: https://requizon.boring-observability.dev/docs/paths
Section: Configuration — Requizon documentation (version 0.2)
Updated: 2026-09-16

---

The hourly rollup, `http_request_stats`, has one row per API, host, path and hour. APIs and hosts are few. Paths are the column that can run away: a REST API puts an identifier in almost every URL, and stored raw, every order number would be its own row in every hour it was fetched. So paths are normalised before anything is stored.

## What the normaliser does

Each segment of the path is checked in turn. A segment becomes `:id` when it is:

- all digits (`8134`)
- a UUID (`2f1c3a9e-7b0d-4c1e-9f55-0c8e6f1d2a47`)
- a ULID (`01J8Z3K9Q4M2V6X7B1C5D8E0FG`)
- a 32, 40 or 64 character hex string, the shape of an MD5, SHA-1 or SHA-256
- longer than `paths.max_segment_length` (40), which catches signed tokens and encoded filenames nobody names by hand

Only the first `paths.max_segments` segments (four) are kept. When there were more, the stored path ends in `/*`, so nobody mistakes the prefix for the endpoint that was called. The query string is never part of the path.

| Called | Stored |
| --- | --- |
| `/v1/customers/cus_Q2x8fL0aZkP3m/invoices` | `/v1/customers/cus_Q2x8fL0aZkP3m/invoices` |
| `/v1/users/8134/orders` | `/v1/users/:id/orders` |
| `/v1/users/8134/orders/99/items` | `/v1/users/:id/orders/*` |
| `/files/2f1c3a9e-7b0d-4c1e-9f55-0c8e6f1d2a47.pdf` | `/files/2f1c3a9e-7b0d-4c1e-9f55-0c8e6f1d2a47.pdf` |
| `/` | `/` |

The first and fourth rows are the heuristic missing. `cus_Q2x8fL0aZkP3m` is an identifier, but it is under 40 characters and not in any of the recognised shapes. A UUID with a file extension is no longer a bare UUID. Both would add a path per customer or per file. The rest of this page is about what to do when your API looks like that.

## Choosing max_segments

Four segments is enough for most REST APIs, and it bounds the damage when an identifier the normaliser missed sits deep in a path. Raise it when the part of the path that tells your endpoints apart comes later, as with APIs that carry a long fixed prefix:

```php
// /CBMS-external/rest/yachtReservation/v6/freeYachts
// with max_segments 4: /CBMS-external/rest/yachtReservation/v6/*
// with max_segments 6: /CBMS-external/rest/yachtReservation/v6/freeYachts
'paths' => [
    'max_segments' => 6,
    'max_segment_length' => 40,
    'max_length' => 255,
    'patterns' => [],
    'other_label' => 'other',
],
```

Changing it starts new rows on the paths page from the deploy onwards. Existing rows keep the paths they were stored with.

## The allow-list

Everything above is a heuristic, and an API with an open-ended URL space will eventually leak through it. The one hard bound is `paths.patterns`. When the list is not empty, a normalised path must match one of its `Str::is()` patterns, or it is stored as `other`:

```php
'paths' => [
    // ...
    'patterns' => [
        '/v1/customers*',
        '/v1/payment_intents*',
        '/v1/refunds*',
        '/',
    ],
    'other_label' => 'other',
],
```

Patterns are matched against the *normalised* path, so write `/v1/users/:id` rather than a pattern for the digits. They apply to every API at once, including the root path, which is why `/` is in the list above. It is empty by default because a default list would quietly relabel every path of every application that never configured one.

`other` is a real bucket: its calls, failures and response times are all counted, just not told apart. If it grows, the requests list for that API still has every call's full path to show you what to add.

## Replacing the normaliser

For an API whose identifiers the heuristic cannot recognise, register a resolver in your `RequizonServiceProvider`. Return the path to store, or `null` to hand the call to the built-in normaliser:

```php
use BoringO11y\Requizon\Requizon;

Requizon::resolvePathUsing(function ($uri, $request) {
    if ($uri->getHost() !== 'api.stripe.com') {
        return null;
    }

    // cus_Q2x8fL0aZkP3m, pi_3NcQ... → :id, while /v1/payment_intents stays
    return preg_replace('#/[a-z]{2,5}_[A-Za-z0-9]{10,}(?=/|$)#', '/:id', $uri->getPath());
});
```

A resolved path is stored as returned, cut to `paths.max_length`. It does not go through `max_segments` or `patterns`, so a resolver is responsible for keeping its own output bounded.

## max_length

The `path` column is `VARCHAR(255)`, and `paths.max_length` cuts the stored value to fit. Leave it at 255 unless you have altered the column. A longer path would be rejected by MySQL in strict mode, and because recording never lets an error reach your call, that row would be silently lost.


## Common questions

### Why does a Requizon path end in /*?

The path had more segments than paths.max_segments (four by default), so Requizon kept the first four and marked the rest with a trailing *. /v1/users/8134/orders/99/items is stored as /v1/users/:id/orders/*. Raise max_segments if the deeper segments are what tells your endpoints apart.

### What does the "other" path mean in Requizon?

You have set paths.patterns, and the call's normalised path matched none of them. Every unmatched path is recorded under one bucket, named by paths.other_label, instead of adding its own rows to the rollup.
