promforecast can publish each forecast through two different write paths. Production deployments should pick one of them as the canonical store; the official charts default to sink + VM for a fresh install. This page explains what each path actually emits, what their trade-offs are, why running both unguarded silently produces wrong-looking dashboards, and how the chart defaults steer you onto the recommended path.
Every fit overwrites a single gauge per (series, model, horizon). When
Prometheus scrapes the forecaster's /metrics endpoint it sees the
end-of-horizon value as a single point at the scrape timestamp; the
intermediate forecast steps from t+1 to t+horizon are not exposed.
- What gets stored. Per scrape interval, one sample per emitted
forecast metric per series, carrying the forecaster's scrape labels
(
instance="<pod>:9091",job="<release>", plus whatevercluster/replicaexternal labels the scraper adds). - What it looks like on a Grafana time-series. A 5-minute staircase — each scrape redraws the gauge at the current value, so the line steps every refresh interval rather than tracing a smooth curve.
- When it's the right pick. Single-replica low-cardinality installs where the snapshot is enough to feed alerts on end-of-horizon thresholds, and you don't need to render the forecast curve as a smooth projection.
After every fit the forecaster pushes the full forecast curve (every
step from t+1 to t+horizon) into a long-term TSDB via
remote_write with native future timestamps. VictoriaMetrics accepts
future samples when started with -futureRetention=2d or higher.
- What gets stored. Per fit, one sample per future step per
series — carrying only the source labels of the underlying signal
(no
instance="<forecaster>:9091", nojob="promforecast"). - What it looks like on a Grafana time-series. A smooth curve
extending from
nowinto the future, with_lower/_upperbands rendered as a shaded interval. Nooffset <horizon>trickery is needed to align actual against predicted. - When it's the right pick. Almost always. Smooth curves are what capacity-planning and deviation-detection dashboards want; the forecast becomes a first-class historical signal queryable with normal PromQL.
Running both paths against the same long-term TSDB without filtering
puts two copies of every *_forecast / *_forecast_lower /
*_forecast_upper series in the store under different labelsets:
- The scraped snapshot copy carries
job="promforecast"(set by the ServiceMonitor'sjobLabel: app.kubernetes.io/name), the forecaster's sourceinstance(preserved byhonorLabels: true), and any external labels Prometheus'remote_writeadds at ingest (cluster,replica). - The sink-pushed copy carries the source signal's labels
(
instance="node-exporter:9100",device, etc.) and nojob— the forecaster pushes directly to VM, bypassing the Prometheus scrape pipeline that would otherwise stamp one on.
The labelset difference therefore reduces to the presence (or absence)
of the job label, plus any external labels the scrape pipeline adds
at remote_write time. Both the helm-test probe in
charts/promforecast-stack/templates/tests/labelset-uniqueness.yaml
and the manual verification snippet below use exactly this asymmetry.
A dashboard panel that does not pre-filter on the labelset renders an average of the two — the smooth sink curve smeared against the 5-minute snapshot staircase. The graph looks plausibly noisy at a glance; the failure mode is silent.
This is the implicit default state of any install that turns the sink on without dropping the snapshot families from the scrape. Both the umbrella chart and the standalone chart fix this by default (see "Recommended path" below).
Production deployments should:
- Enable the sink. Set
config.sink.remote_write.enabled: trueand point it at the long-term TSDB. - Drop the snapshot families from the scrape. Apply a
metric_relabel_configsblock (ormetricRelabelingson the ServiceMonitor) that drops.+_forecast(_lower|_upper)?. The operationalforecast_*metrics (forecast_quality_score,forecast_failures_total,forecast_accuracy_mape, …) and the derived snapshot-only families (*_forecast_time_to_threshold_seconds,*_forecast_growth_rate,*_forecast_contribution_ratio) stay scraped — they only exist on/metrics. - Point dashboards at VM as the default datasource. The bundled
dashboards under
dashboards/grafana/declare VictoriaMetrics (uid: victoriametrics) as their templating default. The umbrella chart can optionally provision the matching datasource entries — setdatasources.enabled: true(off by default to avoid colliding with installations that already manage Grafana datasources out-of-band). When enabled, the chart writes a sidecar-loaded ConfigMap pointinguid: victoriametricsat the bundled VM. If Grafana already has a default datasource, setdatasources.victoriaMetrics.isDefault: falseso the bundled provisioning does not collide with it — the dashboards keep working because they reference the explicit UID, not the global default.
The official charts ship that combination on by default:
- The umbrella
promforecast-stackchart setspromforecast.config.sink.remote_write.enabled: trueandpromforecast.serviceMonitor.enabled: true. The forecaster's ServiceMonitor then derives the snapshot drop from the sink toggle automatically, so a freshhelm installlands on sink+VM with the dual-source trap closed. - The standalone
promforecastchart exposesserviceMonitor.dropForecastSnapshots(defaultnull). Whennullit resolves to the value ofconfig.sink.remote_write.enabled: the drop is on when the sink is on, off when the sink is off. Pin totrue/falseto override the derivation.
For an externally-managed Prometheus (not driven by prometheus-operator), copy this snippet into the scrape job that targets the forecaster:
scrape_configs:
- job_name: promforecast
honor_labels: true
static_configs:
- targets: ["forecaster:9091"]
metric_relabel_configs:
- source_labels: [__name__]
regex: '.+_forecast(_lower|_upper)?'
action: dropThe two knobs do different things and both matter:
metric_relabel_configsdrops the snapshot copy of the curve families (*_forecast,*_forecast_lower,*_forecast_upper) so only the sink-pushed copy lives in the TSDB. Without this the dual-source trap reappears.honor_labels: truestops Prometheus from overwriting theinstancelabel that the forecaster exposes on its operational and derived*_forecast_*families (forecast_quality_score,*_forecast_time_to_threshold_seconds,*_forecast_growth_rate,*_forecast_contribution_ratio). Those families inherit the source signal'sinstance(e.g.node-exporter:9100) and the example alerts inexamples/alerts/promforecast-rules.yamlinterpolate{{ $labels.instance }}to identify the underlying host. Default Prometheus scraping would clobber that with the forecaster pod's own address;honor_labels: truepreserves it. The umbrella chart's ServiceMonitor sets the equivalenthonorLabels: true.
After helm install and at least one successful refresh (the forecaster's
refresh_interval, one hour by default, so the first scheduled fit
must have completed before the sink push lands any data in VM), query
VM directly to confirm the curve families exist under exactly one
labelset. PromQL matcher values must be double-quoted, so the snippets
below use single-quoted shell wrappers to pass them through verbatim:
# Expect ``data: []`` — no forecast series under the forecaster scrape job.
curl -s --data-urlencode 'match[]={__name__=~".+_forecast(_lower|_upper)?",job="promforecast"}' \
"$VM/api/v1/series"
# Expect a non-empty data array — sink-pushed copies (no scrape job).
curl -s --data-urlencode 'match[]={__name__=~".+_forecast(_lower|_upper)?",job=""}' \
"$VM/api/v1/series"
The umbrella chart bundles this exact pair of probes as a helm test
hook (charts/promforecast-stack/templates/tests/labelset-uniqueness.yaml)
so you can run helm test <release> instead of curling by hand. The
hook polls VM for up to tests.firstRefreshTimeout (default 5m) so
it tolerates the post-install delay before the first scheduled refresh
lands the sink push.
The forecaster's operational metrics — forecast_quality_score,
forecast_accuracy_mape, forecast_deviation_outside_band,
forecast_deviation_ratio, forecast_failures_total, and the
threshold / growth-rate derived families — are emitted on /metrics and
inherit the same (id, group, model, instance, …) source labels as
the underlying series. This is intentional and aligned with design
principle 8 (label alignment with the sibling promanomaly project):
keeping the source instance (and other join keys) on the operational
metrics lets you write and on (id, group, instance) <source_metric>
PromQL joins without label-rewriting recording rules. The scrape labels
(job="promforecast", instance="<pod>:9091") sit alongside as
metadata; PromQL's on (...) joins are explicit about which labels
they care about, so the extra metadata does not break the join.
The sink-pushed curve families (*_forecast, *_forecast_lower,
*_forecast_upper) carry only source labels — no job, no forecaster
instance. The scraped operational families
(forecast_quality_score, forecast_deviation_outside_band, …) carry
source labels plus job="promforecast". That asymmetry is by
design — it lets you join across them as long as you are explicit about
the join keys.
Wrong (relies on the default "everything matches" join):
# Returns zero results — RHS has ``job="promforecast"`` that LHS lacks,
# so the implicit full-labelset match never finds a peer.
forecast_deviation_outside_band == 1
and forecast_quality_score > 0.6
Right (explicit on(...) restricts the join to keys present on both
sides):
forecast_deviation_outside_band{level="95"} == 1
and on (id, group, model) forecast_quality_score > 0.6
The bundled example alerts in examples/alerts/promforecast-rules.yaml
use exactly this form. If you copy them and drop the on(...) clause
the rule silently matches zero series.
| Concern | Snapshot only | Sink + VM (recommended) |
|---|---|---|
| Cardinality footprint | one sample per series per scrape | one sample per series per future step per fit |
| Visual quality on Grafana | 5-minute staircase | smooth forward curve |
| Forecast history queryable as PromQL | only the latest gauge | full curve back to install time |
| Query cost | cheap (single point per series) | proportional to horizon × refresh density |
| Storage cost | minimal | grows with horizon and refresh interval |
| TSDB requirements | any Prometheus-compatible store | must accept future-timestamped writes (VM -futureRetention, Mimir, Thanos receive) |
| Setup complexity | one config block (snapshot is on by default) | sink config + future retention + scrape-drop |
| Dual-source risk | n/a | mitigated by scrape-drop |
The cardinality and storage figures cut both ways: the sink path is
strictly more expensive per emission, but it eliminates the need for
offset <horizon> Grafana tricks and gives you forecast history as a
queryable time-series instead of just a current-snapshot gauge.
- The umbrella chart values that ship sink-first:
charts/promforecast-stack/values.yaml. - The standalone chart knob that derives the drop:
charts/promforecast/values.yaml(serviceMonitor.dropForecastSnapshots). - The example alert rules that assume sink labels:
examples/alerts/promforecast-rules.yaml. - The bundled dashboards that default to VM:
dashboards/grafana/.