/accounts/{account}, never per raw URL. It uses the http.route your framework sets when there is one. Otherwise it normalizes the raw path with a fixed set of rules that replace Stellar addresses, ids, hashes and tokens with placeholders. Values that match no rule are stored as they are, so http.route is the better source wherever you can set it.
How it works
The writer resolves one route for every histogram point, inapps/writer/src/normalize.ts, then applies the per-service route cap in apps/writer/src/ingest.ts.
Which attribute is used
The first attribute that holds a non-empty string wins. The path rules below run on it whichever it is. A template such as
/users/:id or /users/{id} has no segment the rules match, so it passes through unchanged. A literal segment in a template is still checked: /v2/2024/report becomes /v2/{id}/report.
Path cleanup
Before the segment rules run, the writer:- keeps only the path of a full URL:
https://example.com/a/42?x=1→/a/{id}; - removes the query string and fragment:
/bounties/?page=2#top→/bounties; - collapses doubled slashes, removes a trailing slash and adds a leading one:
//api//v1/health/→/api/v1/health; - percent-decodes each segment before matching it:
/files/my%20report→/files/my report.
Segment rules
Each segment between slashes is checked against these rules in order. The first match replaces the whole segment.StrKey is the address codec in @stellar/stellar-sdk. These examples are the output of normalizePath on real input:
web3-builders-hackathon-2026 is a single case and has fewer than 20% digits, so it stays.
Why the checksum matters
A Stellar address is base32 text that encodes a version byte, the key or contract ID, and a two-byte CRC16 checksum. The version byte gives the first letter:G for an account, C for a contract, M for a muxed account. Lumenstat decodes the segment and verifies the checksum, so a string with the right first letter and length is not enough.
Change the last character of the account above and the result is kept as it is:
{account}, {contract} or {muxed}. It can still become {token} under rule 8 if enough of its characters are digits. Otherwise it is stored unchanged.
The 500-route cap
Each service can have at most 500 distinct routes. When the writer first sees a service after it starts, it loads the routes that service stored in the last 30 days. From then on a route it has not seen is admitted while the service has fewer than 500. After that, every new route is stored as{other}, with its counts and latency added to that row. Routes already admitted keep their names.
The cap is per service, so a second service in the same project has its own 500. Self-hosters can change the limit with the writer’s SCF_ROUTE_LIMIT variable.
A large {other} row on the API page means a service is sending values the rules do not recognize. Set http.route for it.
Why the rules are fixed
- Cardinality. Lumenstat stores one row per service, route, method, status code and minute. With raw paths, every account, order and transaction becomes its own route. Storage would grow with traffic instead of with the size of your API, and each route would hold a handful of requests, too few for a meaningful p95.
- Privacy. A Stellar account in a URL is a stable public identifier. Storing it per request would record which accounts called which endpoint, and when. A placeholder keeps the count and drops the identity. The same goes for UUIDs, numeric ids and tokens, and a JWT in a path is a credential.
- Comparable numbers. SCF operators compare usage across projects.
/accounts/{account}means the same thing in every project because every project gets the same rules. The rule set is hard-coded on purpose. Projects that need finer control sethttp.routethemselves.
Set http.route yourself
Many server frameworks have an OpenTelemetry instrumentation that setshttp.route from the matched route template, which is the best name a route can have. See Node.js for Express and NestJS, and Collector and other languages for Java, Python, Go and Rust. On a server without a router, set http.route on the active server span in your handler.
Limits
- Values that match no rule are stored. An email address, a username or a short id is kept as written:
/users/aliceand/users/alice@example.comstay unchanged, and so does/files/invoice-12345.pdf. If such values identify people, sethttp.route. - Rules see whole segments only. An address inside a larger segment is not found. A Stellar asset written as
USDC:GA5YQHLXHOEFNQA563Z3FOOMCXQJOVLLT62O2UOSJLLZI27YJEZKCWOLstays as it is, so each asset becomes its own route. - Lowercase addresses are not recognized. StrKey encoding is upper case, so a lowercased account fails the check and is kept.
- The cap does not judge. If a service fills its 500 routes with unnormalized values, later legitimate routes land in
{other}too. - Rules apply to
http.routeas well. A template with a literal numeric or hex segment has that segment replaced.