6c871586

Created Started Finished

PromQL as a type rather than as text

PromQL as a type rather than as text, extracted from the dashboard generator
in nix-ci so that the question "is it worth doing" could be answered by doing
it.

A query assembled by concatenating strings carries the grammar in its author's
head. Three things go wrong that way and all three are silent: the quoting of
a label's value, the backslashes in a matcher's regular expression, which are
read twice over, and the parentheses around a division, which PromQL accepts
either way and answers differently.

`PromQL.Expr` is the grammar as a type and `PromQL.Render` is the one thing
that writes any PromQL, so it is the one thing that has to be right about it.
The brackets are the renderer's to put in, and so is the layout, which breaks
at the loosest operator first.

## What it covers

Selectors with their matchers and offsets, range vectors and subqueries, the
aggregations with their `by` and `without` clauses, the binary operators with
their precedence and their vector matching, and twenty-two named functions.

Deliberately not every PromQL function. A function is a constructor and a line
in the renderer, which is the point: a query cannot name one that does not
exist. Adding the one you need is a two-line change.

There is no parser. Nothing here reads PromQL, only writes it.

## What says any of this is true

`promql-e2e`, which starts a real Prometheus and asks it. Nothing in a Haskell
repository can decide whether a query is valid PromQL, or whether two
spellings of one mean the same thing, so this does not try:

- every expression the generators can write parses;
- leaving out the brackets the grammar does not need means the same as writing
  every one of them in, which is how the precedence table is checked against
  the one Prometheus implements;
- breaking a query across lines means the same as writing it on one.

`promtool promql format` cannot answer the second, because it keeps whatever
brackets it was given. The `/api/v1/parse_query` endpoint hands back the tree.

That check found seven things wrong while it was being written, which is the
argument for having it:

- `vector()` with no argument, because arity was a list rather than a shape
- label names that are not identifiers, which the server rejects by pointing
  inside a brace
- `log2` of a scalar, because PromQL has a type system
- `on()` between a vector and a scalar
- an offset written before a range rather than after it
- a comparison between two scalars without the bool modifier
- a lone surrogate in a label's value

The first five were the types and the renderer. The last two were the
generators.

## What the types promise, and what they do not

`isValid` means Prometheus will take it, with one documented exception.

A review of the first two commits found four kinds of value that were valid
here and refused there. Three are closed in the third commit, and they turned
out to be one rule: a matcher that matches the empty string also matches every
series that does not carry the label, so a selector made only of those still
asks for everything. `{l!="v"}`, `{l=""}` and `{l=~".*"}` are all refused for
that reason, and `Validity` decides it now for every regular expression this
type builds itself.

A `Pattern` is taken at its word. Whether one matches the empty string is a
question of running a regular expression, and refusing the ones that might
would turn a legal query, such as the `{unit=~"prefix.+"}` a log stream is
picked out with, into a value this type will not hold.

The exception is PromQL's own type system. `log2` takes an instant vector and
will not take a scalar; `clamp_min` takes a vector and a scalar in that order;
vector matching is only allowed between two vectors. `Expr` can hold the wrong
one of each and `isValid` will say yes. `PromQL.Gen` is where those rules are
written down, so the end-to-end check covers the subset the generators can
write rather than everything the types allow. Making it unrepresentable means
indexing `Expr` by what it evaluates to, which is a different type and a much
larger one. The README says so rather than leaving it to be inferred.

## Checks

`nix flake check` is green: pre-commit, hlint, statix, deadnix, weeder-nix,
hopinion, a coverage floor of 80%, the end-to-end check against a real
Prometheus, and mutation testing at **783 killed, 0 surviving**.

The mutation check is worth a word, because getting it to zero changed the
code twice rather than only the tests. It found that the layout put brackets
around a call, which is an atom and so can never be the thing that needs them:
dead code, deleted rather than suppressed. And that three `Validity` instances
ran `genericValidate` over a newtype whose only field is a `Word` or a `Text`,
which is trivially valid, so they were checking nothing.

`weeder.toml` roots the API written out rather than the `PromQL.` module
prefix. With the prefix as a root every binding in the library is a root and
the check can never report anything; with the API as the root set the
internals are rooted by being reachable from it, which is the version that
does work.

## Size

1858 lines of Haskell when the second commit landed, 362 of Nix and
configuration, across three packages: `promql` (the types and the renderer),
`promql-gen` (generators and the unit suite), `promql-e2e` (the Prometheus
client and the end-to-end properties).

## What this does not do

Nothing in nix-ci depends on this. #747 there carries its own copy of the
same ideas, scoped to the nineteen functions those dashboards ask for, and
nothing in this repository is wired into it. Whether to make nix-ci depend on
this is a separate decision, and the honest input to it is that extracting
the library found seven real bugs in a weekend and left one known hole at its
centre.