<!-- canonical: https://efficientnewlanguage.org/eml-p/projects/P133-metric-labels/ | updated: 2026-10-17 -->

# P133 Metric labels

Requests counted into time series, one per combination of label values: which labels keep taking new values as the traffic grows, how many series each labelling makes, and what counters and histograms cost to keep.

EML-P project `projects/metric-labels` in the EML language repo: 2 module(s), entry `main.eml`, terminal UI. There, `eml project run projects/metric-labels` runs it and `eml project verify projects/metric-labels` replays every session under CPython (two hash seeds) and in the interpreter; the site build replays every session in the interpreter again and publishes a session only if its screen matches.

Built on verified corpus cases: metric-cardinality-explosion (https://efficientnewlanguage.org/cases/241-metric-cardinality-explosion/), counter-vs-histogram (https://efficientnewlanguage.org/cases/235-counter-vs-histogram/).

## Sessions

### bad-input - interpreter: byte-equal to the golden

Input:

```text
0
x
5

5
method method
5
method colour
5
path
5
method status
6
```

Screen:

```text
== Metric labels ==
Requests counted into time series, one series for each combination of label values.

1) requests  2) labels  3) series  4) storage  5) your own labels  6) quit
choice> 0
Pick a number from 1 to 6.

1) requests  2) labels  3) series  4) storage  5) your own labels  6) quit
choice> x
Pick a number from 1 to 6.

1) requests  2) labels  3) series  4) storage  5) your own labels  6) quit
choice> 5
Labels from method, status, path, endpoint and user, separated by spaces, such as method endpoint.
labels> 
Cancelled.

1) requests  2) labels  3) series  4) storage  5) your own labels  6) quit
choice> 5
Labels from method, status, path, endpoint and user, separated by spaces, such as method endpoint.
labels> method method
Name each label once.

1) requests  2) labels  3) series  4) storage  5) your own labels  6) quit
choice> 5
Labels from method, status, path, endpoint and user, separated by spaces, such as method endpoint.
labels> method colour
Type labels from method, status, path, endpoint and user.

1) requests  2) labels  3) series  4) storage  5) your own labels  6) quit
choice> 5
Labels from method, status, path, endpoint and user, separated by spaces, such as method endpoint.
labels> path
path: 89 series at 200 requests, 402 at 1000; declared 5.
  path kept taking new values as the traffic grew: these series have no bound.

1) requests  2) labels  3) series  4) storage  5) your own labels  6) quit
choice> 5
Labels from method, status, path, endpoint and user, separated by spaces, such as method endpoint.
labels> method status
method status: 19 series at 200 requests, 20 at 1000; declared 20.
  No label here took a new value; the extra series are combinations of values already seen, filling in.

1) requests  2) labels  3) series  4) storage  5) your own labels  6) quit
choice> 6
Bye.
```

### basic - interpreter: byte-equal to the golden

Input:

```text
1
2
3
4
5
method endpoint
5
status user
6
```

Screen:

```text
== Metric labels ==
Requests counted into time series, one series for each combination of label values.

1) requests  2) labels  3) series  4) storage  5) your own labels  6) quit
choice> 1
The first 8 requests (method, status, path, user):
   1  POST   200  /health           user 8
   2  GET    500  /orders/1034      user 6
   3  PUT    200  /orders/1033      user 6
   4  GET    200  /search           user 1
   5  GET    200  /users/6/orders   user 6
   6  GET    201  /health           user 4
   7  POST   200  /orders/1056      user 10
   8  PUT    404  /orders/1006      user 12
Every path is one of /users/<id>/orders, /users/<id>, /orders/<id>, /health and /search.

1) requests  2) labels  3) series  4) storage  5) your own labels  6) quit
choice> 2
Distinct values each label takes, as the traffic grows:
  label      declared   200 requests   1000 requests
  method            4              4               4
  status            5              5               5
  path              5             89             402   grows with the traffic
  endpoint          5              5               5
  user              -             47             204   grows with the traffic
path holds the ids written into it; endpoint is the path with every number replaced by :id.

1) requests  2) labels  3) series  4) storage  5) your own labels  6) quit
choice> 3
Series to store, one per combination of label values seen, at 1000 requests:
  labels                        declared   seen
  method status                       20     20
  method status endpoint             100     98
  method status path                 100    627
  method status endpoint user  unbounded    944
Declared counts multiply every value with every other, so they are upper bounds when every value is bounded; a label carrying ids has no bound at all.

1) requests  2) labels  3) series  4) storage  5) your own labels  6) quit
choice> 4
Numbers kept per series: a counter is 1; a histogram of 8 buckets, with its sum and count, is 10.
  labels                        series   counters   histograms
  method status                     20         20          200
  method status endpoint            98         98          980
  method status path               627        627         6270
  method status endpoint user      944        944         9440
Only a histogram can answer a percentile later; it costs ten numbers a series, and every series is kept for the whole retention.

1) requests  2) labels  3) series  4) storage  5) your own labels  6) quit
choice> 5
Labels from method, status, path, endpoint and user, separated by spaces, such as method endpoint.
labels> method endpoint
method endpoint: 20 series at 200 requests, 20 at 1000; declared 20.

1) requests  2) labels  3) series  4) storage  5) your own labels  6) quit
choice> 5
Labels from method, status, path, endpoint and user, separated by spaces, such as method endpoint.
labels> status user
status user: 106 series at 200 requests, 501 at 1000; declared unbounded.
  user kept taking new values as the traffic grew: these series have no bound.

1) requests  2) labels  3) series  4) storage  5) your own labels  6) quit
choice> 6
Bye.
```

## Modules

### main.eml

```eml
# P133 metric labels: the requests, the values each label takes as traffic
# grows, the series each labelling makes, what storing them costs, and a
# labelling of your own.
import labels

def plain(s):
    0 => i
    len(s) => j
    while i < j and s[i] == " ":
        i + 1 => i
    while j > i and s[j - 1] == " ":
        j - 1 => j
    return s[i:j]

def pad(s, n):
    s => out
    while len(out) < n:
        out + " " => out
    return out

def lpad(s, n):
    s => out
    while len(out) < n:
        " " + out => out
    return out

def declared(label):
    # the values the label was declared with; 0 for none
    if label == "method":
        return 4
    if label == "status":
        return 5
    if label == "path" or label == "endpoint":
        return 5
    return 0

def show_requests():
    "The first 8 requests (method, status, path, user):" ^0
    for i in [1:8]:
        labels.request(i) => r
        ("  " + lpad(str(i), 2) + "  " + pad(r[0], 7) + pad(str(r[1]), 5) + pad(r[2], 18) + "user " + str(r[3])) ^0
    "Every path is one of /users/<id>/orders, /users/<id>, /orders/<id>, /health and /search." ^0

def show_labels():
    "Distinct values each label takes, as the traffic grows:" ^0
    "  label      declared   200 requests   1000 requests" ^0
    for l in labels.labels():
        labels.distinct(200, l) => a
        labels.distinct(1000, l) => b
        "-" => d
        if declared(l) > 0:
            str(declared(l)) => d
        "" => note
        if b > a:
            "   grows with the traffic" => note
        ("  " + pad(l, 9) + lpad(d, 10) + lpad(str(a), 15) + lpad(str(b), 16) + note) ^0
    "path holds the ids written into it; endpoint is the path with every number replaced by :id." ^0

def labellings():
    return [["method", "status"], ["method", "status", "endpoint"], ["method", "status", "path"], ["method", "status", "endpoint", "user"]]

def names(ls):
    "" => out
    for l in ls:
        if out != "":
            out + " " => out
        out + l => out
    return out

def predicted(ls):
    1 => p
    for l in ls:
        if declared(l) == 0:
            return 0 - 1
        p * declared(l) => p
    return p

def show_series():
    "Series to store, one per combination of label values seen, at 1000 requests:" ^0
    "  labels                        declared   seen" ^0
    for ls in labellings():
        predicted(ls) => p
        "unbounded" => d
        if p >= 0:
            str(p) => d
        ("  " + pad(names(ls), 28) + lpad(d, 10) + lpad(str(labels.series(1000, ls)), 7)) ^0
    "Declared counts multiply every value with every other, so they are upper bounds when every value is bounded; a label carrying ids has no bound at all." ^0

def show_storage():
    "Numbers kept per series: a counter is 1; a histogram of 8 buckets, with its sum and count, is 10." ^0
    "  labels                        series   counters   histograms" ^0
    for ls in labellings():
        labels.series(1000, ls) => n
        ("  " + pad(names(ls), 28) + lpad(str(n), 8) + lpad(str(n), 11) + lpad(str(10 * n), 13)) ^0
    "Only a histogram can answer a percentile later; it costs ten numbers a series, and every series is kept for the whole retention." ^0

def own():
    "Labels from method, status, path, endpoint and user, separated by spaces, such as method endpoint." ^0
    plain(input("labels> ")) => t
    if t == "":
        "Cancelled." ^0
        return
    [] => ls
    "" => w
    for c in t + " ":
        if c == " ":
            if w != "":
                False => ok
                for l in labels.labels():
                    if l == w:
                        True => ok
                if not ok:
                    "Type labels from method, status, path, endpoint and user." ^0
                    return
                for x in ls:
                    if x == w:
                        "Name each label once." ^0
                        return
                ls + [w] => ls
            "" => w
        else:
            w + c => w
    labels.series(200, ls) => a
    labels.series(1000, ls) => b
    predicted(ls) => p
    "unbounded" => d
    if p >= 0:
        str(p) => d
    (names(ls) + ": " + str(a) + " series at 200 requests, " + str(b) + " at 1000; declared " + d + ".") ^0
    [] => growing
    for l in ls:
        if labels.distinct(1000, l) > labels.distinct(200, l):
            growing + [l] => growing
    if len(growing) > 0:
        ("  " + names(growing) + " kept taking new values as the traffic grew: these series have no bound.") ^0
    elif b > a:
        "  No label here took a new value; the extra series are combinations of values already seen, filling in." ^0

"== Metric labels ==" ^0
"Requests counted into time series, one series for each combination of label values." ^0
True => running
while running:
    "" ^0
    "1) requests  2) labels  3) series  4) storage  5) your own labels  6) quit" ^0
    plain(input("choice> ")) => choice
    if choice == "1":
        show_requests()
    elif choice == "2":
        show_labels()
    elif choice == "3":
        show_series()
    elif choice == "4":
        show_storage()
    elif choice == "5":
        own()
    elif choice == "6":
        False => running
    else:
        "Pick a number from 1 to 6." ^0
"Bye." ^0
```

Python projection of main.eml:

```python
import labels

def plain(s):
    i = 0
    j = len(s)
    while i < j and s[i] == " ":
        i = i + 1
    while j > i and s[j - 1] == " ":
        j = j - 1
    return s[i:j]

def pad(s, n):
    out = s
    while len(out) < n:
        out = out + " "
    return out

def lpad(s, n):
    out = s
    while len(out) < n:
        out = " " + out
    return out

def declared(label):
    if label == "method":
        return 4
    if label == "status":
        return 5
    if label == "path" or label == "endpoint":
        return 5
    return 0

def show_requests():
    print("The first 8 requests (method, status, path, user):")
    for i in range(1, 9):
        r = labels.request(i)
        print("  " + lpad(str(i), 2) + "  " + pad(r[0], 7) + pad(str(r[1]), 5) + pad(r[2], 18) + "user " + str(r[3]))
    print("Every path is one of /users/<id>/orders, /users/<id>, /orders/<id>, /health and /search.")

def show_labels():
    print("Distinct values each label takes, as the traffic grows:")
    print("  label      declared   200 requests   1000 requests")
    for l in labels.labels():
        a = labels.distinct(200, l)
        b = labels.distinct(1000, l)
        d = "-"
        if declared(l) > 0:
            d = str(declared(l))
        note = ""
        if b > a:
            note = "   grows with the traffic"
        print("  " + pad(l, 9) + lpad(d, 10) + lpad(str(a), 15) + lpad(str(b), 16) + note)
    print("path holds the ids written into it; endpoint is the path with every number replaced by :id.")

def labellings():
    return [["method", "status"], ["method", "status", "endpoint"], ["method", "status", "path"], ["method", "status", "endpoint", "user"]]

def names(ls):
    out = ""
    for l in ls:
        if out != "":
            out = out + " "
        out = out + l
    return out

def predicted(ls):
    p = 1
    for l in ls:
        if declared(l) == 0:
            return 0 - 1
        p = p * declared(l)
    return p

def show_series():
    print("Series to store, one per combination of label values seen, at 1000 requests:")
    print("  labels                        declared   seen")
    for ls in labellings():
        p = predicted(ls)
        d = "unbounded"
        if p >= 0:
            d = str(p)
        print("  " + pad(names(ls), 28) + lpad(d, 10) + lpad(str(labels.series(1000, ls)), 7))
    print("Declared counts multiply every value with every other, so they are upper bounds when every value is bounded; a label carrying ids has no bound at all.")

def show_storage():
    print("Numbers kept per series: a counter is 1; a histogram of 8 buckets, with its sum and count, is 10.")
    print("  labels                        series   counters   histograms")
    for ls in labellings():
        n = labels.series(1000, ls)
        print("  " + pad(names(ls), 28) + lpad(str(n), 8) + lpad(str(n), 11) + lpad(str(10 * n), 13))
    print("Only a histogram can answer a percentile later; it costs ten numbers a series, and every series is kept for the whole retention.")

def own():
    print("Labels from method, status, path, endpoint and user, separated by spaces, such as method endpoint.")
    t = plain(input("labels> "))
    if t == "":
        print("Cancelled.")
        return
    ls = []
    w = ""
    for c in t + " ":
        if c == " ":
            if w != "":
                ok = False
                for l in labels.labels():
                    if l == w:
                        ok = True
                if not ok:
                    print("Type labels from method, status, path, endpoint and user.")
                    return
                for x in ls:
                    if x == w:
                        print("Name each label once.")
                        return
                ls = ls + [w]
            w = ""
        else:
            w = w + c
    a = labels.series(200, ls)
    b = labels.series(1000, ls)
    p = predicted(ls)
    d = "unbounded"
    if p >= 0:
        d = str(p)
    print(names(ls) + ": " + str(a) + " series at 200 requests, " + str(b) + " at 1000; declared " + d + ".")
    growing = []
    for l in ls:
        if labels.distinct(1000, l) > labels.distinct(200, l):
            growing = growing + [l]
    if len(growing) > 0:
        print("  " + names(growing) + " kept taking new values as the traffic grew: these series have no bound.")
    elif b > a:
        print("  No label here took a new value; the extra series are combinations of values already seen, filling in.")

print("== Metric labels ==")
print("Requests counted into time series, one series for each combination of label values.")
running = True
while running:
    print("")
    print("1) requests  2) labels  3) series  4) storage  5) your own labels  6) quit")
    choice = plain(input("choice> "))
    if choice == "1":
        show_requests()
    elif choice == "2":
        show_labels()
    elif choice == "3":
        show_series()
    elif choice == "4":
        show_storage()
    elif choice == "5":
        own()
    elif choice == "6":
        running = False
    else:
        print("Pick a number from 1 to 6.")
print("Bye.")
```

### labels.eml

```eml
# P133 metric labels - a request log counted into time series, one series
# per distinct combination of label values. As in the corpus case
# metric-cardinality-explosion, a label that looks bounded can carry an id:
# /users/17/orders and /users/99/orders are two series to the metrics
# system and one endpoint to a person. Which labels grow is measured on the
# data, at two volumes of traffic. As in counter-vs-histogram, each series
# costs a counter, or a whole histogram.
#
# A request is [method, status, path, user].

def mix(i, salt):
    # a number from 0 to 999999, the same for the same request and salt;
    # squaring modulo a prime, so nearby requests do not move together
    i * 7919 + salt * 104729 + 12345 => x
    for k in [1:3]:
        (x * x + 7) % 1000000007 => x
    return x % 1000000

def request(i):
    ["GET", "GET", "GET", "POST", "PUT", "DELETE"] => methods
    [200, 200, 200, 200, 201, 404, 500, 503] => statuses
    methods[mix(i, 1) % 6] => m
    statuses[mix(i, 2) % 8] => s
    # users grow with the traffic: request i comes from one of the first
    # i / 4 + 10 users
    1 + mix(i, 3) % (int(i / 4) + 10) => user
    mix(i, 4) % 5 => k
    if k == 0:
        "/users/" + str(user) + "/orders" => p
    elif k == 1:
        "/users/" + str(user) => p
    elif k == 2:
        "/orders/" + str(1000 + mix(i, 5) % (i + 50)) => p
    elif k == 3:
        "/health" => p
    else:
        "/search" => p
    return [m, s, p, user]

def template(path):
    # every all-digit segment of the path replaced by :id
    "" => out
    "" => seg
    for c in path + "/":
        if c == "/":
            if seg != "":
                True => digits
                for d in seg:
                    if d < "0" or d > "9":
                        False => digits
                if digits:
                    out + "/:id" => out
                else:
                    out + "/" + seg => out
            "" => seg
        else:
            seg + c => seg
    if out == "":
        return "/"
    return out

def value(r, label):
    if label == "method":
        return r[0]
    if label == "status":
        return str(r[1])
    if label == "path":
        return r[2]
    if label == "endpoint":
        return template(r[2])
    return str(r[3])

def labels():
    return ["method", "status", "path", "endpoint", "user"]

def series(n, chosen):
    # the distinct combinations of the chosen labels over requests 1 to n
    {} => seen
    for i in [1:n]:
        request(i) => r
        "" => key
        for l in chosen:
            key + "|" + value(r, l) => key
        1 => seen[key]
    return len(seen)

def distinct(n, label):
    {} => seen
    for i in [1:n]:
        1 => seen[value(request(i), label)]
    return len(seen)
```

Python projection of labels.eml:

```python
def mix(i, salt):
    x = i * 7919 + salt * 104729 + 12345
    for k in range(1, 4):
        x = (x * x + 7) % 1000000007
    return x % 1000000

def request(i):
    methods = ["GET", "GET", "GET", "POST", "PUT", "DELETE"]
    statuses = [200, 200, 200, 200, 201, 404, 500, 503]
    m = methods[mix(i, 1) % 6]
    s = statuses[mix(i, 2) % 8]
    user = 1 + mix(i, 3) % (int(i / 4) + 10)
    k = mix(i, 4) % 5
    if k == 0:
        p = "/users/" + str(user) + "/orders"
    elif k == 1:
        p = "/users/" + str(user)
    elif k == 2:
        p = "/orders/" + str(1000 + mix(i, 5) % (i + 50))
    elif k == 3:
        p = "/health"
    else:
        p = "/search"
    return [m, s, p, user]

def template(path):
    out = ""
    seg = ""
    for c in path + "/":
        if c == "/":
            if seg != "":
                digits = True
                for d in seg:
                    if d < "0" or d > "9":
                        digits = False
                if digits:
                    out = out + "/:id"
                else:
                    out = out + "/" + seg
            seg = ""
        else:
            seg = seg + c
    if out == "":
        return "/"
    return out

def value(r, label):
    if label == "method":
        return r[0]
    if label == "status":
        return str(r[1])
    if label == "path":
        return r[2]
    if label == "endpoint":
        return template(r[2])
    return str(r[3])

def labels():
    return ["method", "status", "path", "endpoint", "user"]

def series(n, chosen):
    seen = {}
    for i in range(1, n+1):
        r = request(i)
        key = ""
        for l in chosen:
            key = key + "|" + value(r, l)
        seen[key] = 1
    return len(seen)

def distinct(n, label):
    seen = {}
    for i in range(1, n+1):
        seen[value(request(i), label)] = 1
    return len(seen)
```

## README

# P133 - Metric labels

Requests counted into time series, one series for each combination of
label values: method, status, path, endpoint (the path with its numbers
replaced by :id) and user.

- `main.eml` - the menu, the requests, the values each label takes, the
  series each labelling makes, what storing them costs, and a labelling
  of your own
- `labels.eml` - the request log, templating a path, a request's label
  values, and the counts of series and of distinct values

How each part works:

- The requests are generated the same every run; the users and the order
  numbers grow with the traffic.
- As in the corpus case `metric-cardinality-explosion`, path was declared
  with 5 values, one per kind of path, and carries ids: 89 values at 200
  requests and 402 at 1000. Which labels grow is measured on the data at
  two volumes, not read from the declaration. Templating the path back to
  its endpoint keeps it at 5.
- At 1000 requests: method and status make 20 series, as declared; adding
  endpoint makes 98 of the 100 declared; adding path instead makes 627.
- When a labelling's series grow while none of its labels takes a new
  value, the new series are combinations of values already seen, filling
  in; they can never pass the product of those values.
- As in the corpus case `counter-vs-histogram`, a counter is one number a
  series and a histogram of 8 buckets with its sum and count is ten, but
  only the histogram can answer a percentile later.

What is checked: menu choices 1 to 6; labels from method, status, path,
endpoint and user, each named once. An empty answer cancels.

Sessions: `sessions/basic.in` gives:
- the requests, the labels, the series and the storage;
- method endpoint, which stays at 20, and status user, which grows.

`sessions/bad-input.in` gives:
- menu choices 0 and x;
- labels: a cancel, method twice, colour;
- path alone, which grows, and method status, which only fills in.

Built on the verified corpus cases `metric-cardinality-explosion` (a label
that looked bounded until a path held an id) and `counter-vs-histogram`
(a summary chosen at write time decides which questions it can answer).
