PromQL compatibility
Watchtower evaluates PromQL natively (crates/wt-promql, parsing by promql-parser). Semantics follow the Prometheus 3.x documentation: left-open range windows (t-range, t], 5 minute lookback, staleness markers. Reference vectors in crates/wt-promql/tests/vectors/prometheus_docs.tsv are written from documented behavior, not copied from any test suite. Watchtower extensions (WITH, default, if, ...) are in metrics-extensions.md.
Supported
Family | Names |
|---|---|
Math |
|
Time (UTC) |
|
Labels, ordering |
|
Range |
|
Aggregation |
|
Histograms |
|
Binary |
|
Info and scalars |
|
Names | UTF-8 metric and label names, quoted: |
Subqueries |
|
bitand bitor bitxor (precedence of *; nonnegative integers below 2^53, else NaN) and duration expressions (x[5m * 2], x[step() * 4], [range()], offset (1h + 30m), min/max of durations) are evaluated as in Prometheus 3.
Delta temporality
OTLP delta sums and histograms are stored with the label __temporality__="delta". rate, increase and irate over such series sum the samples in the window (no extrapolation) and drop the label; rollup tiers keep the sum of the deltas in a bucket. sum_over_time(x[5m]) gives the same increase directly. Joining raw delta series with raw cumulative series is refused with HTTP 422 (errorType: execution on the Prometheus API, unprocessable on native routes) and a message naming the fix: wrap both sides in rate() or increase().
anchored and smoothed
rate(x[5m] anchored) starts the window at the last sample at or before its start (within the lookback), without extrapolation; smoothed interpolates the start value between the samples around the window start and the end value between the samples around the window end, reading up to the 5 m lookback past it. A counter reset across either edge is not interpolated.
Subqueries
Inner instants are the multiples of step inside (t-range, t] (aligned to absolute time, as Prometheus does). The default step is the evaluation interval: the query step for a range query, 60 s for an instant query. Nesting depth is at most 3 (deeper answers 422), at most 11,000 inner steps per subquery (422), and every inner evaluation counts against the 50M-unit cost budget of the query ("query too expensive", 422).
An instant query whose subquery covers more than a day and is a plain function argument runs that subquery as its own range query (split and parallel), then applies the outer function (subquery spin-off). offset and @ on that subquery are honoured. Results are unchanged; anything else (nested subqueries run inside the outer one's range query) takes the normal path.
Duration expressions accept Prometheus' max_of(a, b) and min_of(a, b), e.g. x[max_of(1m, step())].
start_timestamp(v) takes a vector selector and returns, per sample, its series start in seconds: the newest start stored at or before the sample. Starts are stored for cumulative float series (counters, summary _count/_sum) from OTLP start_time_unix_nano and remote-write v2 created_timestamp (metric_start_v1). Series without a known start (gauges, delta series, histograms, senders that set none) are left out of the result.
Native histograms
OpenTelemetry exponential histograms and remote-write native histograms are stored natively (metric_nhist_v1, schema at most 8, finer ones merged down) and read through SampleSource::select_native as NativeHist (schema, zero bucket, sparse positive and negative buckets). A project may store them as classic le buckets instead, or both (metrics settings exp_histograms). A selector that matches native series yields native samples. Quantiles interpolate exponentially inside a bucket and linearly around the zero bucket; histogram_fraction interpolates linearly. rate, increase (counter-reset aware, extrapolated like float series) and sum work over native histograms; histograms of different schemas are merged at the coarser schema.
Mixing classic and native samples for the same series, or using a native vector in a binary operator, answers 422
cannot mix classic and native histogram series in one expressionnaming the label set.A query whose final result is a native histogram answers 422; wrap it in a
histogram_*function,rateorsumfirst.Custom-bucket native histograms (NHCB, schema -53, from remote write) are stored as classic
lebuckets and read byhistogram_quantile.Native histograms have no rollup tier: long ranges read them raw (within raw retention).
avg,min,maxand the other aggregations over native histograms answer 422.
Limits
Query length 8192 bytes, tree depth 64, 2000 nodes, 50M cost units, 11,000 points per series for range queries. Per project (/metrics/settings): series per result (default 10,000), points per query (default 3,000,000), longest range and lookback, blocked and rate-limited queries.
Prometheus API frontend
/v1/projects/{prj}/prometheus/api/v1/query_range splits long raw ranges into parallel 24 h chunks, routes long ones to rollups and caches results (nocache=1 skips the cache). stats=all adds a stats block. status/buildinfo reports version 3.0.0 so Grafana enables Prometheus 3 features; format_query pretty-prints a query.
Sharding. A raw range of at least 1 h whose top level is
sum,count,min,max,avg,group,topkorbottomk(literal k) over a per-series expression runs on 4 series shards in parallel and is merged (avg= shard sums / shard counts).quantile,stddev, cross-series operators, nested aggregations and subqueries run unsharded.Fetch caps.
max_fetched_series_per_query/max_fetched_samples_per_query(0 = off) stop the storage read while it runs (422 when exceeded), on top of the result caps checked afterwards.Remote read.
POST .../prometheus/api/v1/readanswers snappyprometheus.ReadRequestwith rawSAMPLES(32 queries, 50,000 series per request). A client listingSTREAMED_XOR_CHUNKSfirst gets a sorted stream of XOR and float histogram chunks of up to 120 samples, read in batches. Both response types return native histograms.Read consistency.
X-Read-Consistency: strongwaits (up to 20 s, else 503) until every metric write acknowledged before the request is stored; it skips the results cache. Default is eventual.Federation.
X-Wt-Projects: <id>,<id>evaluates over several projects of the same org the caller can read (else 404); each series carries__project__. The header applies toquery,query_range,series,labels,label/{name}/values,query_exemplarsandmetadata: a__project__matcher inmatch[]picks projects,__project__is listed as a label and its values are the project ids. Each project is read under the caller's own data access policy there, and the window is clamped to the shortest metrics retention among them. Federated answers are cached per set of projects and the caller's scope in each. Recording rule groups takesource_projectsthe same way.Search.
search/metric_names,search/label_namesandsearch/label_values?label=takesearch(substring, any case) orfuzzy=true(its characters in order),limit(1 to 1000) andstart/end, and answer{name, series}rows withhas_more. The native API has the same asGET /metrics/search.Cardinality.
cardinality/label_names,cardinality/label_values?label_names[]=,cardinality/active_series?selector=andcardinality/active_native_histogram_metricsfollow Mimir's parameters (selector,limit,count_method=active(20 min) orinmemory(3 h)) and response shapes. The Cardinality view in Metrics uses them.Limits.
user_limitsanduser_statsanswer with Mimir's field names;GET /metrics/limitsis the native document (quota, series caps, settings and rule limits, plus active series, bytes today and samples per second).Series cap across workers.
quotas.max_seriesholds for the project as a whole, however many workers ingest: new series are claimed from one shared budget per minute. It can be passed only by series that arrive within the write delay around a minute boundary; if Postgres is unreachable, each worker decides alone until it is back.Plan explain.
analyze?query=lists the tree as evaluated after rewrites and planner passes, one row per node, with the matchers storage receives and notes (statically empty,shared input,narrowable).Usage.
GET /metrics/cost-attribution(samples received / discarded and active series per value of the configuredcost_attribution_labels),GET /metrics/active-series/trackers(named selectors), andPOST /metrics/backfillsto evaluate a recording rule over a past range (90 days, 200,000 steps, 5 open per project) into storage.Planner. Repeated selectors in one query are read once, number arithmetic is folded, and matchers cross binary operators (
a{env="p"} / on (env) breads onlyb{env="p"}; not acrossor,fill*or into the left ofunless). Series, labels and label-values answers are cached, except their last 5 minutes, which are read live, so new series show at once.Delayed name removal.
enable_delayed_name_removalin/metrics/settings(off by default, as in Prometheus 3'spromql-delayed-name-removal) keeps__name__until the end of a query, solabel_replaceandby (__name__)still see it afterrateand the like. It applies to the Prometheus API, recording rules, backfills and alert rules. Aby (__name__)overratewith two or more names fails with a duplicate-labelset error, as in Prometheus.Delta temporality on long ranges. Rollup buckets of delta series hold the sum of their deltas, for floats, classic and native histograms, so
rateandincreasestay correct without reading raw samples.
Not supported
Each name below answers 422 unsupported_promql naming it (checked by unsupported_list_matches_doc).
<!-- unsupported:begin -->
Name | Why |
|---|---|
<!-- unsupported:end --> |
Query planning passes
Matcher propagation.
sum by (env) (a{env="p"}) / on (env) sum by (env) (b)reads onlyb{env="p"}. Matchers cross binary operators and pass through parentheses, per-series functions, subqueries andby/withoutaggregations, in the same cases as Mimir's query engine. They do not crossororfill*, the left ofunlesstakes nothing from its right, and they do not pass throughlabel_*,absent,scalar,count_values, or a grouping that removes the label.Difference from Prometheus. Valid queries give the same result. Prometheus checks every series on the "one" side for duplicates, also in groups that cannot match, and fails with a many-to-many error. Here (and in Mimir) those series are not read:
a{env="p"} * on (env) bsucceeds when onlyb{env="q"}has duplicates. A duplicate in a group that matches still fails.Narrowed selectors. In
a op on (l) b, when one side reads only instant selectors that keepl, it is read first and the other side reads only thelvalues it saw (l=~"v1|v2"), up to 256 values.rate(x[5m]) * on (instance) group_left (node) node_uname_inforeads only thexseries of instances that have an info series. Not applied inside subqueries, acrossororfill*, or to the left ofunless.Split long windows. In an instant query,
sum_over_time,count_over_time,min_over_timeandmax_over_timeof a selector over more than two days (up to 120) run per UTC day and are combined; whole days are cached (60 s while the day ended within the last hour, then 1 h), except for callers under a data access policy.rateandincreaseare not split, because their edge extrapolation does not add up across days; long ranges of those use the rollup tiers.Reduced matchers. Duplicates, matchers an equality already implies (
a="x", a=~"x|y") and=~".*"are not sent to storage.Statically empty parts are not read: contradictory matchers (
a{x="1",x="2"}),vector(1) == 2,timestamp(x) < cbefore any visible sample, and whatever+,andorunlessthen makes empty. An unsupported function on a skipped side answers empty instead of 422.Shared inputs.
sum(rate(x[5m])) / count(rate(x[5m]))computes the input once per step.histogram_count,histogram_sumandhistogram_avgdirectly over a selector,rate,increaseorsumdo not read bucket arrays.