Skip to main content
Lumenstat counts requests per route, such as /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, in apps/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.
The finished route is cut to 256 characters.

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:
The token rule keeps readable slugs. 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:
A string that fails the checksum is never labeled {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 set http.route themselves.

Set http.route yourself

Many server frameworks have an OpenTelemetry instrumentation that sets http.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/alice and /users/alice@example.com stay unchanged, and so does /files/invoice-12345.pdf. If such values identify people, set http.route.
  • Rules see whole segments only. An address inside a larger segment is not found. A Stellar asset written as USDC:GA5YQHLXHOEFNQA563Z3FOOMCXQJOVLLT62O2UOSJLLZI27YJEZKCWOL stays 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.route as well. A template with a literal numeric or hex segment has that segment replaced.
Last modified on November 14, 2026