Compare commits

..

55 Commits

Author SHA1 Message Date
Hui Wang
b4a40d75ae vmalert: skip a redundant pending state when an alert can be restored to firing 2026-08-14 17:17:47 +08:00
f41gh7
7c7dc7068d deployment/docker: update Go builder from Go1.26.5 to Go1.26.6
See https://github.com/golang/go/issues?q=milestone%3AGo1.26.6%20label%3ACherryPickApproved
2026-08-14 10:11:50 +02:00
Hui Wang
d0b202a50d app/vmalert: polish logs
1. Expose `series_fetched` in the rule debug log.
2. Add the group file path to the logs, so the group can be traced back
to a specific file when there are multiple groups with the same name.

Related PR https://github.com/VictoriaMetrics/VictoriaMetrics/pull/11408
2026-08-14 09:39:13 +02:00
JAYICE
342bd964ce lib/workingsetcache: persist the prev cache on shutdown if prev cache is more valuable than curr cache
Previously workingsetcache will persist the curr cache on
shutdown regardless of cache miss rate, size or etc. It could cause slow
ingestion in vmstorage in slow query in vmselect if it was shutdown just
after the cache rotation.

 This commit saves cache in a split mode with lowest miss rate (prev or curr).

It prefers to presist prev cache when:
1. the cache miss ratio in curr cache is higher than 80%
2. the cache hit ratio in prec cache is higher than 20%.

fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/11299
2026-08-14 09:35:38 +02:00
Evgenii M
690f02f19c lib/promrelabel: speed up matching multiple if expressions
When vmagent uses `-remoteWrite.urlRelabelConfig` to select metrics for a
particular `-remoteWrite.url`. Which is typical configuration for routing rules between multiple remote writes. And configs contain a `keep`
rule with a long list of `if` expressions:

```yaml
- action: keep
  if:
    - '{cluster="production",namespace=~"team-.+"}'
    - 'some_fat_metric{cluster="production"}'
```

At millions of samples per second, walking the full list for every
sample becomes expensive. This showed up as a noticeable part of
`vmagent` CPU usage in profiles.

This commit add fast path for selectors with an exact metric name at `IfExpression.Match`. Such as
`foo{bar="baz"}` or `{__name__="foo"}`. And it rejects the selector
before running the rest of its filters.


On production load this reduced `vmagent` CPU usage by roughly 15%.
The actual effect depends on the sample rate and on how many expressions
in the relabel config have distinct exact metric names.

 benchstat

```
goos: darwin
goarch: arm64
pkg: github.com/VictoriaMetrics/VictoriaMetrics/lib/promrelabel
cpu: Apple M1 Pro
                                                             │    before    │                after                │
                                                             │    sec/op    │   sec/op     vs base                │
IfExpressionAlternatives/single_exact_match/labels_16-10        7.723n ± 0%   7.236n ± 3%   -6.32% (p=0.001 n=10)
IfExpressionAlternatives/single_exact_match/labels_48-10        7.697n ± 1%   7.286n ± 1%   -5.35% (p=0.000 n=10)
IfExpressionAlternatives/distinct_exact_6_miss/labels_16-10     30.25n ± 1%   12.14n ± 1%  -59.87% (p=0.000 n=10)
IfExpressionAlternatives/generic_6_miss/labels_16-10            75.70n ± 0%   78.09n ± 1%   +3.16% (p=0.000 n=10)
IfExpressionAlternatives/mixed_6_miss/labels_16-10              36.86n ± 0%   22.02n ± 1%  -40.27% (p=0.000 n=10)
IfExpressionAlternatives/distinct_exact_6_miss/labels_48-10     30.37n ± 0%   12.18n ± 0%  -59.88% (p=0.000 n=10)
IfExpressionAlternatives/generic_6_miss/labels_48-10            160.4n ± 1%   162.0n ± 0%   +1.03% (p=0.001 n=10)
IfExpressionAlternatives/mixed_6_miss/labels_48-10              50.76n ± 1%   35.13n ± 0%  -30.79% (p=0.000 n=10)
IfExpressionAlternatives/distinct_exact_32_miss/labels_16-10   158.20n ± 1%   53.80n ± 1%  -65.99% (p=0.000 n=10)
IfExpressionAlternatives/generic_32_miss/labels_16-10           365.4n ± 1%   383.6n ± 0%   +4.98% (p=0.000 n=10)
IfExpressionAlternatives/mixed_32_miss/labels_16-10            185.15n ± 1%   96.99n ± 1%  -47.61% (p=0.000 n=10)
IfExpressionAlternatives/distinct_exact_32_miss/labels_48-10   156.40n ± 1%   54.21n ± 1%  -65.34% (p=0.000 n=10)
IfExpressionAlternatives/generic_32_miss/labels_48-10           810.1n ± 2%   836.9n ± 0%   +3.30% (p=0.000 n=10)
IfExpressionAlternatives/mixed_32_miss/labels_48-10             247.5n ± 1%   158.9n ± 0%  -35.82% (p=0.000 n=10)
geomean                                                         79.69n        52.15n       -34.56%
```

Related PR https://github.com/VictoriaMetrics/VictoriaMetrics/pull/11341/
2026-08-14 09:32:28 +02:00
Cody Kaczynski
f9df52255e lib/promscrape: add Linode service discovery
This commit implements Linode Service Discovery based on [Prometheus Linode discovery](https://github.com/prometheus/prometheus/tree/main/discovery/linode).

```
scrape_configs:
  - job_name: linode
    linode_sd_configs:
      - port: 9100
        authorization:
          credentials_file: /root/linode-sd-test/token
```

Fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9118
2026-08-14 09:26:16 +02:00
Fred Navruzov
c9e85290df docs: update vmanomaly for v1.30.2 (#11400)
## Summary

- update vmanomaly documentation and deployment examples for v1.30.2 and
VMUI v1.8.2
- move stable KPI policies to `reader.queries`, document model-level
compatibility fallbacks as deprecated, and update recommended examples
to online models with bootstrap-only fit schedules
- document bounded reader concurrency, native numerical-thread limits,
fit-data cleanup, grouped multivariate memory improvements, and
order-independent multivariate inference
- record v1.30.2 persisted-state compatibility with v1.30.1
- updated some diagrams for better UX
2026-08-13 22:41:23 +03:00
Dhruvan Tanna
1b242a8c71 app/vmselect: scale default concurrency with available CPUs (#11205)
Fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/11191

The default `-search.maxConcurrentRequests` for cluster `vmselect` was capped at 16 via `min(2*cgroup.AvailableCPUs(), 16)`. This meant larger vmselect nodes kept the same default concurrency once they had more than 8 CPUs.

This changes the default to `2*cgroup.AvailableCPUs()`, matching the style already used by `vmstorage` and
`clusternative.maxConcurrentRequests`.

Capping it at 16 makes sense for vmsingle, since it handles both ingestion and querying, with ingestion being the higher priority. Select has no such limitation and should scale to all available resources.

---------

Co-authored-by: Max Kotliar <mkotlyar@victoriametrics.com>
2026-08-13 17:08:00 +03:00
JAYICE
c1f3589248 app/vmalert: properly update eval_delay and eval_alignment on config reload (#11380)
Fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/11374

Signed-off-by: “Jayice” <jzhou@victoriametrics.com>
Co-authored-by: Max Kotliar <mkotlyar@victoriametrics.com>
2026-08-13 16:52:34 +03:00
Max Kotliar
389ad7933e docs/changelog: follow-up on prev commit 1a1c61083d 2026-08-13 16:23:27 +03:00
Kirill Yurkov
1a1c61083d app/vmalert: rename vmalert_rule_group_results_limit back to vmalert_group_rule_results_limit (#11375)
rename `vmalert_rule_group_results_limit` back to
`vmalert_group_rule_results_limit`. The metric was introduced in
[v1.147.0](https://docs.victoriametrics.com/victoriametrics/changelog/#v11470)
but was accidentally given the wrong name. Restoring the right metric
name.

---------

Co-authored-by: Hui Wang <haley@victoriametrics.com>
Co-authored-by: Max Kotliar <mkotlyar@victoriametrics.com>
2026-08-13 16:14:48 +03:00
Artem Fetishev
8b59f970f3 lib/storage: follow-up for 15a21d9791 (#11398)
Restore checking globalIndexTimeRange instead of db.s.disableGlobalIndex since
currently indexDB has no control on which time range it receives from storage
and it receives globalIndexTimeRange.

See https://github.com/VictoriaMetrics/VictoriaMetrics/pull/11398#discussion_r3774317249

Signed-off-by: Artem Fetishev <rtm@victoriametrics.com>
2026-08-13 14:38:25 +02:00
Artem Fetishev
15a21d9791 lib/storage: check early whether an indexDB contains the time range (#11398)
Each indexDB public method that accepts a time range or date now
performs this check explicitly and the very beginning. This check
existed before, but was not obvious and performed in the middle of the
search request. Performing this check early allows to avoid any
unnecessary computations an sometimes even index searches.

Related to #11196.

---------

Signed-off-by: Artem Fetishev <rtm@victoriametrics.com>
2026-08-13 13:46:34 +02:00
Nikolay
abdd6d853e lib/promscrape: properly detect IPv6 address for docker discovery
Previously service discovery was not able to discover IP address at IPv6
only networks.

 This commit adds fall-back for GlobalIPv6Address field at container
network.

Fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10965
2026-08-12 18:24:44 +02:00
f41gh7
b4b14ede65 docs/changelog: re-order changelog entries 2026-08-12 18:20:20 +02:00
面壁
e31e58185c lib/netutil: avoid IPv6 hint for Unix socket errors
`NewStatDialFuncWithDial` currently appends an `-enableTCP6` suggestion
when the logical target address is not an IPv4 literal. For Unix domain
socket scrapes, that address is the HTTP target's `host:port`, while the
custom dialer connects to a captured socket path.

This produces misleading errors such as:

```text
dial unix /run/exporter.sock: connect: no such file or directory; try -enableTCP6 command-line flag for dialing ipv6 addresses
```

Remove the hint only when the actual dial error identifies a Unix
network address. The transport is obtained from the structured
`net.OpError` returned by the custom dialer, since it is not represented
by the logical address passed by `http.Transport`.

The existing TCP6 hint remains unchanged for standard TCP dialing and
other custom dialers, including proxy dialers. Regression tests cover
plain and wrapped Unix errors, TCP errors, and malformed `net.OpError`
values.

Related PR https://github.com/VictoriaMetrics/VictoriaMetrics/pull/11320
2026-08-12 18:10:16 +02:00
Phuong Le
20ffe1f679 lib/logstorage: return discarded in-memory parts to pool
Returns interrupted or empty in-memory merge results to the pool

Related PR https://github.com/VictoriaMetrics/VictoriaMetrics/pull/11340
2026-08-12 18:06:12 +02:00
Vandit Singh
7afd7c2a16 lib/protoparser/vmimport: skip labels with empty name
Sending a series with an empty label name to `/api/v1/import` stored it
under the wrong name. This request:

```
{"metric":{"__name__":"foo","":"bar","ok":"yes"},"values":[1],"timestamps":[456]}
```

was stored as `bar{ok="yes"}`. The name that was sent is gone and the
label value took its place. The response is 204 and nothing is logged.

This happens because a label with an empty name is equal to `__name__`,
as noted in `AddLabelBytes` in `app/vminsert/common/insert_ctx.go`. That
equivalence is intentional there, so the check belongs in the parser
instead.

`unmarshalTags` now skips tags with an empty name. Empty values are
still allowed, matching the Prometheus text parser, which allows them on
purpose per #453.

The other parsers already do this. The Prometheus text parser skips
empty names when it collects tags, and the graphite, influx and opentsdb
parsers skip empty tags too. This makes the JSON parser consistent with
them.

Fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/4962
2026-08-12 18:05:29 +02:00
Hui Wang
f8f87f316b vmselect: change HTTP response code from 422 to 400 when request parameters are missing or incorrect (#11348)
Fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/11330

Align with
https://prometheus.io/docs/prometheus/latest/querying/api/#format-overview,
return `400` instead of `422` when request parameters are missing or
incorrect and should be fixed on the caller side.
One exception is when the request hits a [resource
limit](https://docs.victoriametrics.com/victoriametrics/cluster-victoriametrics/#resource-usage-limits),
such as `search.maxPointsPerTimeseries`; it could be fixed by the
caller(reducing the request range) or the admin(increasing the limit),
but it is not caused by incorrect parameters, so in this case, still
return 422.

---------

Co-authored-by: Max Kotliar <mkotlyar@victoriametrics.com>
2026-08-12 14:29:20 +03:00
Max Kotliar
2777800bc2 app/vmagent: properly stop remotewrite in TestInsertHandler teardown (#11391)
The test was not calling `remotewrite.Stop()` in tearDown, which left
file descriptors for the persisted queue open. Running the test multiple
times would eventually exhaust fd limits and cause a panic.

Adding `remotewrite.Stop()` exposed a second issue:
`configReloaderStopCh` was initialized at package level, so it was
already closed after the first `Stop()` call. Re-initializing Init() (as
in `go test ./app/vmagent/prometheusimport/ -count=2`) would then panic
on a closed channel.

Fixed by moving `configReloaderStopCh` and `configReloaderWG`
initialization into Init(), so each Init/Stop cycle starts with a fresh
channel.

Decoupled from
https://github.com/VictoriaMetrics/VictoriaMetrics/pull/11354, where
@zasdaym originally identified the issue.
2026-08-12 12:24:00 +03:00
Victoria Nduka
398a3d74aa doc/victoriametrics: fix typos and grammar in keyConcepts.md (#11389)
This PR fixes a couple of typos and grammatical errors in the key
concepts doc for the VictoriaMetrics component.

---------

Signed-off-by: Victoria Nduka <122698422+nwanduka@users.noreply.github.com>
Co-authored-by: Max Kotliar <mkotlyar@victoriametrics.com>
Co-authored-by: Pablo Fernandez <46322567+TomFern@users.noreply.github.com>
2026-08-12 12:23:02 +03:00
Max Kotliar
aa32d59cc8 Revert "docs/changelog: add update note about vmalert logsqql breaking change"
This reverts commit 578754ef49.
2026-08-12 12:17:37 +03:00
Hui Wang
d4a40004ef app/vmalert: tolerate 400 evaluation error with -replay.continueWithExecutionErr in replay mode
Follow up https://github.com/VictoriaMetrics/VictoriaMetrics/pull/11348

VM would then return `400` for missing or incorrect parameters, and
`422` for evaluation errors such as `duplicate time series on the left
side`, or when the request hits a [resource
limit](https://docs.victoriametrics.com/victoriametrics/cluster-victoriametrics/#resource-usage-limits).
`-replay.continueWithExecutionErr` should work with both status codes.
2026-08-11 22:59:36 +02:00
Zhu Jiekun
8f4fdf0ae3 chore: unify the management of secret flags by app
fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/11295
2026-08-11 22:58:16 +02:00
f41gh7
339a1b355c app/vminsert|vmselect|vmagent: enable --enableMultitenancyViaHeaders by default
This change aligns VictoriaMetrics multitenancy behavior with
VictoriaLogs and VictoriaTraces.

See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/11365
2026-08-11 22:54:14 +02:00
Max Kotliar
578754ef49 docs/changelog: add update note about vmalert logsqql breaking change 2026-08-11 12:15:34 +03:00
Max Kotliar
d88c3f6447 docs/changelog: add update note about vmalert logsql breaking change
See
https://github.com/VictoriaMetrics/VictoriaLogs/blob/master/docs/victorialogs/CHANGELOG.md#v1510,
dce8193c16
2026-08-11 12:10:03 +03:00
Yury Moladau
8a3757d21b app/vmui: fix custom step synchronization between state and URL (#11350)
### Describe Your Changes

Fix custom query step synchronization in vmui.

Previously, the custom step specified via the `g0.step_input` URL
parameter could be overwritten by the automatically calculated step
during page initialization. This could also potentially cause the vmui
Dashboards page to freeze.

Related issue: #11137

Signed-off-by: Yury Molodov <yurymolodov@gmail.com>
Co-authored-by: Max Kotliar <mkotlyar@victoriametrics.com>
2026-08-10 19:29:10 +03:00
Yury Moladau
b6952cf346 app/vmui: add an option to customize the favicon color (#11331)
Make VictoriaMetrics instances easier to distinguish by allowing users to
customize the favicon color. This PR also refreshes the Settings modal
layout and controls.

Mirror of https://github.com/VictoriaMetrics/VictoriaLogs/pull/1646
Related issue: https://github.com/VictoriaMetrics/VictoriaMetrics/pull/1634

<img width="497" height="47" alt="image"
src="https://github.com/user-attachments/assets/a250fbb1-b21a-4425-92f7-3aee6dc78a2b"
/>

Changes:
- add per-instance favicon color customization with 12 predefined colors
and a reset option
- persist the selected color in `localStorage` and synchronize it across
browser tabs
- refresh the Settings modal layout and controls

### Browser compatibility

Safari keeps the default favicon because it doesn't support data URL
favicons.

### Screenshots

| Before | After |
|---|---|
| <img width="643" height="726" alt="image"
src="https://github.com/user-attachments/assets/4fda4423-011f-4721-bf21-355135d62a80"
/> | <img width="643" height="726" alt="image"
src="https://github.com/user-attachments/assets/2eee5767-bcad-4e06-85bc-e74704d0f660"
/> |

Signed-off-by: Yury Molodov <yurymolodov@gmail.com>
Co-authored-by: Max Kotliar <mkotlyar@victoriametrics.com>
2026-08-10 19:22:11 +03:00
Andrii Chubatiuk
d142a1682f docs: replace distributed chart links with VMDistributed CR (#11364)
replaced links to deprecated distributed chart with ones to
VMDistributed resource
2026-08-10 14:38:51 +03:00
Hui Wang
bcb653611c docs/changelog: fix misplaced changelog entry location (#11373) 2026-08-10 14:28:01 +03:00
Pablo (Tomas) Fernandez
6cc9a6a2f3 docs: add missing frontmatter in READMEs. Remove duplicate URLs (#11367)
Some README.md do not have a frontmatter. This leads to duplicated URLs
when published.

For example, these two URLs render the same page, which leads to
duplication. The first one should return 404.
- https://docs.victoriametrics.com/opentelemetry/readme/index.html 
- https://docs.victoriametrics.com/opentelemetry/

This PR adds missing frontmatter so all duplicate URLs are removed and
never rendered.

```yaml
---
build:
  list: never
  publishResources: false
  render: never
sitemap:
  disable: true
---
```

(issue reported by @hagen1778, thank you!)
2026-08-10 14:25:35 +03:00
Fred Navruzov
c620cb30b8 docs: update vmanomaly for v1.30.1 (#11363)
## Summary

- Updated examples to prefer online models.
- Marked offline models for future deprecation.
- Updated vmanomaly documentation and deployment references for v1.30.1.
2026-08-06 17:57:56 +03:00
JAYICE
0033834d3c lib/timeutil: properly parse small unix timestamps with fractional part (#11335)
Fix timeutil.TryParseUnixTimestamp returing different results for
equivalent integer and fractional timestamps, such as `12` and `12.0`.

See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/11324

Signed-off-by: “Jayice” <jzhou@victoriametrics.com>
2026-08-06 15:28:42 +02:00
Max Kotliar
ce7b1fba58 .github/build: fix Go build cache collisions between cross-compile targets (#11356)
setup-go's built-in cache uses the runner's OS/arch in the key, not the
target GOOS/GOARCH. When multiple cross-compile jobs run in parallel on
the same runner, they share the same cache key and overwrite each other.

Replace setup-go caching (cache: false) with an explicit actions/cache
step whose key includes matrix.os and matrix.arch, giving each target
its own isolated cache slot. 

The change significantly improves build time. See screenshots.

On master:
<img width="1062" height="638" alt="Screenshot 2026-08-05 at 18 02 43"
src="https://github.com/user-attachments/assets/008bf237-4116-4f65-81fe-30d50bb39295"
/>
<img width="1047" height="625" alt="Screenshot 2026-08-05 at 18 02 59"
src="https://github.com/user-attachments/assets/3abc047f-9430-4ae9-a50a-2bfad13d7e5d"
/>

This commit:
<img width="1073" height="744" alt="Screenshot 2026-08-05 at 17 57 56"
src="https://github.com/user-attachments/assets/3cc0d3eb-b88a-472d-8261-2f25f6788256"
/>
<img width="1058" height="744" alt="Screenshot 2026-08-05 at 17 58 03"
src="https://github.com/user-attachments/assets/9c592596-7577-4503-8482-080bd739a4ac"
/>
2026-08-05 18:19:22 +03:00
Max Kotliar
8855e983b9 docs: update flags with actual v1.149.0 binaries
Signed-off-by: Max Kotliar <mkotlyar@victoriametrics.com>
2026-08-05 15:48:53 +03:00
Max Kotliar
6b3dc18654 docs: bump version to v1.149.0
Signed-off-by: Max Kotliar <mkotlyar@victoriametrics.com>
2026-08-05 15:40:28 +03:00
Max Kotliar
e24adb1501 deplyoment/docker: bump version to v1.149.0
Signed-off-by: Max Kotliar <mkotlyar@victoriametrics.com>
2026-08-05 15:39:20 +03:00
Max Kotliar
0c2dd583c8 docs: forward port LTS v1.136.15 changelog to upstream
Signed-off-by: Max Kotliar <mkotlyar@victoriametrics.com>
2026-08-05 15:37:13 +03:00
Max Kotliar
029540c356 docs: forward port LTS v1.148.1 changelog to upstream
Signed-off-by: Max Kotliar <mkotlyar@victoriametrics.com>
2026-08-05 15:36:44 +03:00
JAYICE
4baba77b15 lib/timeutil: refactor the format of uint64 in time_test (#11353)
Make long numbers used in tests more readable.

Extracted from
https://github.com/VictoriaMetrics/VictoriaMetrics/pull/11335 to reduce
diff in the original PR.

Signed-off-by: “Jayice” <jzhou@victoriametrics.com>
2026-08-05 11:27:44 +02:00
Max Kotliar
a8759a539c docs/changelog: cut release v1.149.0
Signed-off-by: Max Kotliar <mkotlyar@victoriametrics.com>
2026-07-31 13:39:51 +03:00
Max Kotliar
f32b743efe docs: update version to v1.149.0
Signed-off-by: Max Kotliar <mkotlyar@victoriametrics.com>
2026-07-31 13:39:32 +03:00
Max Kotliar
8fbf865d9e app/vmselect: run make vmui-update
Signed-off-by: Max Kotliar <mkotlyar@victoriametrics.com>
2026-07-31 13:37:07 +03:00
Max Kotliar
80b6b56028 apptest: fix flaky TestClusterSearchWithDisabledPerDayIndex (#11336)
The test used hardcoded ports for vmstorage instances, which could
already be in use by other processes or tests, causing intermittent
failures like:

    cannot create a server with -vminsertAddr=127.0.0.1:62002:
    unable to listen vminsertAddr 127.0.0.1:62002:
    listen tcp4 127.0.0.1:62002: bind: address already in use

The ports were hardcoded to ensure consistent sharding across cluster
restarts so that the same metrics land on the same storage. However, the
test only verifies query correctness when the per-day index is disabled,
not sharding behavior. Sharding is already covered by
`TestClusterVminsertShardsDataVmselectBuildsFullResultFromShards`.

Switch to a single vmstorage with dynamic ports to eliminate the
flakiness without losing test coverage.
2026-07-31 12:55:21 +03:00
Artem Fetishev
5bdcc5050e lib/storage: reserve 1970-01-01 date for global index search (#11326)
VictoriaMetrics does not support samples with negative timestamps and
limits the min timestamp to `0` (i.e. `1970-01-01T00:00:00Z`).

While samples from the first day (`1970-01-01`) are currently valid (can
be ingested and retrieved), this first day has a special meaning in
`vmstorage` and is used to indicate the search in `global index` instead
of `per-day index`.

This will stop working once the `global index` will be disabled
(https://github.com/VictoriaMetrics/VictoriaMetrics/pull/11196).
I.e. when search is performed on `1970-01-01`, `vmstorage`
will return no results even if the samples exist.

To fix this, we reserve `1970-01-01` for internal use, and allow samples
to have timestamps starting from `1970-01-02`:
- Ingested samples with timestamps from `1970-01-01` will be rejected
and will increment the `vm_rows_ignored_total{reason="small_timestamp"}`
metric.
- Searches whose time range falls completely within the `1970-01-01`
will return empty results.

---------

Signed-off-by: Artem Fetishev <rtm@victoriametrics.com>
2026-07-31 11:19:00 +02:00
Hill Patel
e425aebbc2 lib/timeutil: accept scientific-notation timestamps with sub-second precision (#11278)
Fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/11268

`/api/v1/query` and `/api/v1/query_range` reject `start`/`end` values written in scientific notation whose mantissa carries more fractional digits than the exponent shifts — e.g. `start=1.784144612388E9` returns
`HTTP 422`, while the mathematically equal `start=1784144612.388` returns `200`. Prometheus accepts both (it parses the timestamp with `strconv.ParseFloat`), so this is a Prometheus-compatibility gap.

**Root cause:** in `lib/timeutil/time.go`,
`tryParseScientificNumberForUnixTimestamp` had a guard `if decimalExp <
len(fracStr) { return 0, false }`. For `1.784144612388E9` the mantissa
has 12 fractional digits and the exponent is 9, so `9 < 12` rejected it
— even though the value (`1784144612.388`) is a valid sub-second
timestamp.

**Fix:** when the exponent leaves sub-second fractional digits (`0 <=
decimalExp < len(fracStr)`), keep the mantissa (already
decimal-point-removed) and pad it up to the nearest milli/micro/nano
boundary so `getUnixTimestampNanoseconds` classifies its unit correctly
— exactly how the equivalent plain fractional timestamp is already
parsed by `TryParseUnixTimestamp`.

Kept deliberately in scope, per the discussion on the issue:
- **Negative exponents** on a fractional mantissa (e.g.
`17841446121e-1`) remain unsupported, as @JayiceZ and @valyala decided.
- Sub-second scientific values too small to be a millisecond-scale
timestamp (e.g. `1.23e1`) remain rejected as before, so **no
previously-rejected input changes behaviour** — the change is purely
additive for the reported class of large sub-second timestamps.

## Test plan

- Added success cases to `TestTryParseUnixTimestamp_Success` covering
`1.784144612388E9`, its lowercase and negative forms, and
`1.5000000005e9`, asserting they equal the corresponding
plain-fractional result.
- Verified `TestTryParseUnixTimestamp_Failure` still passes unchanged
(`1.23e1`, `1.234e0`, `1E-1`, negative-exponent forms all still
rejected).
- `go test ./lib/timeutil/`, `go vet ./lib/timeutil/`, and `gofmt` all
clean.

## Disclosure

This change was prepared with AI assistance; I have reviewed the diff,
verified the root cause against the source, and am responsible for the
change and able to explain it.

---------

Co-authored-by: Max Kotliar <mkotlyar@victoriametrics.com>
2026-07-30 21:02:33 +03:00
beyond-infra
f52771ceaf lib/streamaggr: fix sum_samples_total non-monotonic output with enable_windows (#11262)
Fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/11261

`sum_samples_total` produces non-monotonic and undercounted output when`enable_windows: true` is set.


When windows are enabled, blue and green window values are initialized via:
```go
nv.blue[idx]  = ac.getValue(nil)
nv.green[idx] = ac.getValue(nv.blue[idx].state())
```

`sum_samples_total`'s `state()` returned `nil` and `getValue()` ignored its argument, so each window maintained an **independent** cumulative sum. They alternated flushing under the same metric name, producing
repeated, undercounted, and non-monotonic values — e.g. `1, 1, 2, 2` instead of `1, 2, 3, 4`.

## Fix

Introduced `sumSamplesAggrValueShared`, mirroring the pattern already used in `total.go`:
- The shared struct holds the cross-window cumulative `total`
- Both windows receive a pointer to the **same** shared instance via
`state()` / `getValue(s)`
- Each flush adds the window-local `delta` into `shared.total` and outputs that value

`sum_samples` (`resetTotalOnFlush=true`) is unchanged — it has no
cumulative state and uses a plain per-window value as before.

## Test

Added a regression test in `streamaggr_synctest_test.go` that sends 4
batches of `delta=1` with `enable_windows: true` and asserts the output
is monotonically `1, 2, 3, 4`. Before the fix the test fails with `1, 1,
2, 2`.

PR https://github.com/VictoriaMetrics/VictoriaMetrics/pull/11262

---------

Co-authored-by: Hui Wang <haley@victoriametrics.com>
Co-authored-by: Max Kotliar <mkotlyar@victoriametrics.com>
2026-07-30 20:41:12 +03:00
Max Kotliar
eeef07836e docs/changelog: chore changelog 2026-07-30 20:32:29 +03:00
面壁
f1a9c61ba0 lib/promscrape: ignore proxies for Unix socket targets
Unix socket scrape targets inherit `http.DefaultTransport.Proxy` through
`httputil.NewTransport`. When proxy environment variables are
configured, HTTP requests over Unix sockets are altered as proxy
requests, while HTTPS scrapes send `CONNECT` to the Unix socket and fail
the TLS handshake.

Unix sockets are explicit local transport endpoints and cannot be
reached through HTTP proxies. Clear the transport proxy for targets
configured with `__unix_socket__`, matching the existing rejection of
`proxy_url` for these targets.
The bug was introduced with Unix socket scraping support in v1.148.0  at https://github.com/VictoriaMetrics/VictoriaMetrics/pull/11193

Related PR https://github.com/VictoriaMetrics/VictoriaMetrics/pull/11318
2026-07-29 18:33:40 +02:00
f41gh7
6cb014fde5 docs/changelog: re-order entries 2026-07-29 15:42:42 +02:00
刘旭
565ecdc4fb app/vmctl: support Prometheus native histograms in remote read mode
This commit add support migrating [Prometheus native
histograms](https://prometheus.io/docs/specs/native_histograms/) in
`vmctl` remote read mode.

- `SAMPLES` mode: process `TimeSeries.Histograms` in addition to
`TimeSeries.Samples`. Previously native histogram samples were silently
ignored.
- `STREAMED_XOR_CHUNKS` mode: dispatch on the chunk encoding and decode
`HISTOGRAM` / `FLOAT_HISTOGRAM` chunks. Previously all chunks were
parsed as XOR, which failed with `EOF` error on native histogram chunks.
Unknown chunk encodings now return an explicit error. `UNKNOWN` (unset)
chunk type is parsed as XOR for compatibility with senders predating
native histograms support.

Native histograms are converted into `_count`, `_sum` and `_bucket`
series with `vmrange` labels in the same way as VictoriaMetrics converts
native histograms received via Prometheus remote write protocol
(`lib/prompb`), so the migration result matches direct remote write
ingestion.

Fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/11292
2026-07-29 15:23:15 +02:00
Hui Wang
06f4fde931 app/vmalert: add flag -replay.continueWithExecutionErr to allow continuing with evaluation errors
Previously, if a rule has a bad syntax, or a query fails due to a bad
expression that causes a duplicate time series on the left side of
error, or the datasource enforces a resource limit that rejects the
request, the replay process exits immediately and the user must fix the
rule before proceeding.

However, since rules are often managed by different teams, bad rules are
not always easy to fix promptly. In such cases, users may want to
continue replaying other rules even when specific rules are problematic.

 This commit adds new `-replay.continueWithExecutionErr` flag to tolerate the 422 response
code from datasource, which indicates that an expression was executed
but failed, see
https://prometheus.io/docs/prometheus/latest/querying/api/#format-overview.
Unlike https://github.com/VictoriaMetrics/VictoriaMetrics/pull/8746,
other errors, such as an unreachable datasource or 5xx responses, are
not covered by this flag, since they indicate a datasource or network
issue that is global in nature rather than specific to a given rule.

Fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/11313
2026-07-29 15:20:09 +02:00
Phuong Le
203eb3a2b4 README: remove retired Go Report Card badge (#11321)
Go Report Card has been sunset: https://goreportcard.com/. Code quality
checks remain covered by the existing CI workflow.
2026-07-29 15:18:34 +02:00
Solomon Wakhungu
7827647b96 app/vmalert: expose evaluation interval as template variable
This commit exposes the alert group's evaluation interval as a new `.Interval`
template variable in vmalert, making it available for use in alert
annotations, labels, and dashboard links without hardcoding.

## Problem

Previously, vmalert templates had access to variables like `.Expr`,
`.ActiveAt`, `.Labels`, `.Value`, `.For`, etc., but not the evaluation
interval. Users generating dashboard links from alert templates need to
know the interval to set the correct time range. Without it, they must
hardcode the interval value, which breaks when the interval changes.

For example, with a 1h evaluation interval, a dashboard link starting
from `.ActiveAt` shows data for the next hour instead of the hour before
the alert became active. With `.Interval`, the link can be generated
relative to the interval.

Fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/11232
2026-07-29 15:13:26 +02:00
164 changed files with 5117 additions and 1728 deletions

View File

@@ -73,11 +73,19 @@ jobs:
id: go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
cache-dependency-path: |
go.sum
Makefile
app/**/Makefile
cache: false
go-version-file: 'go.mod'
- name: Cache Go build artifacts
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: go-build-${{ matrix.os }}-${{ matrix.arch }}-${{ hashFiles('go.sum', 'Makefile') }}
restore-keys: |
go-build-${{ matrix.os }}-${{ matrix.arch }}-
- run: go version
- name: Build victoria-metrics for ${{ matrix.os }}-${{ matrix.arch }}

View File

@@ -2,7 +2,6 @@
[![Latest Release](https://img.shields.io/github/v/release/VictoriaMetrics/VictoriaMetrics?sort=semver&label=&filter=!*-victorialogs&logo=github&labelColor=gray&color=gray&link=https%3A%2F%2Fgithub.com%2FVictoriaMetrics%2FVictoriaMetrics%2Freleases%2Flatest)](https://github.com/VictoriaMetrics/VictoriaMetrics/releases)
[![Docker Pulls](https://img.shields.io/docker/pulls/victoriametrics/victoria-metrics?label=&logo=docker&logoColor=white&labelColor=2496ED&color=2496ED&link=https%3A%2F%2Fhub.docker.com%2Fr%2Fvictoriametrics%2Fvictoria-metrics)](https://hub.docker.com/u/victoriametrics)
[![Go Report](https://goreportcard.com/badge/github.com/VictoriaMetrics/VictoriaMetrics?link=https%3A%2F%2Fgoreportcard.com%2Freport%2Fgithub.com%2FVictoriaMetrics%2FVictoriaMetrics)](https://goreportcard.com/report/github.com/VictoriaMetrics/VictoriaMetrics)
[![Build Status](https://github.com/VictoriaMetrics/VictoriaMetrics/actions/workflows/build.yml/badge.svg?branch=master&link=https%3A%2F%2Fgithub.com%2FVictoriaMetrics%2FVictoriaMetrics%2Factions)](https://github.com/VictoriaMetrics/VictoriaMetrics/actions/workflows/build.yml)
[![License](https://img.shields.io/github/license/VictoriaMetrics/VictoriaMetrics?labelColor=green&label=&link=https%3A%2F%2Fgithub.com%2FVictoriaMetrics%2FVictoriaMetrics%2Fblob%2Fmaster%2FLICENSE)](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/master/LICENSE)
[![Join Slack](https://img.shields.io/badge/Join%20Slack-4A154B?logo=slack)](https://slack.victoriametrics.com)

View File

@@ -63,6 +63,7 @@ func main() {
flag.CommandLine.SetOutput(os.Stdout)
flag.Usage = usage
envflag.Parse()
initSecretFlags()
buildinfo.Init()
logger.Init()
@@ -170,3 +171,9 @@ See the docs at https://docs.victoriametrics.com/victoriametrics/
`
flagutil.Usage(s)
}
// initSecretFlags manages the secret flags for this app and must be called after flag parsing and before logger init.
func initSecretFlags() {
pushmetrics.InitSecretFlags()
vmselect.InitSecretFlags()
}

View File

@@ -84,7 +84,7 @@ var (
maxLabelNameLen = flag.Int("maxLabelNameLen", 0, "The maximum length of label names in the accepted time series. Series with longer label name are ignored. In this case the vm_rows_ignored_total{reason=\"too_long_label_name\"} metric at /metrics page is incremented")
maxLabelValueLen = flag.Int("maxLabelValueLen", 0, "The maximum length of label values in the accepted time series. Series with longer label value are ignored. In this case the vm_rows_ignored_total{reason=\"too_long_label_value\"} metric at /metrics page is incremented")
enableMultitenancyViaHeaders = flag.Bool("enableMultitenancyViaHeaders", false, "Enables multitenancy via HTTP headers. "+
enableMultitenancyViaHeaders = flag.Bool("enableMultitenancyViaHeaders", true, "Enables multitenancy via HTTP headers. "+
"See https://docs.victoriametrics.com/victoriametrics/vmagent/#multitenancy")
)
@@ -115,7 +115,7 @@ func main() {
flag.CommandLine.SetOutput(os.Stdout)
flag.Usage = usage
envflag.Parse()
remotewrite.InitSecretFlags()
initSecretFlags()
buildinfo.Init()
logger.Init()
opentelemetry.Init()
@@ -843,3 +843,9 @@ See the docs at https://docs.victoriametrics.com/victoriametrics/vmagent/ .
`
flagutil.Usage(s)
}
// initSecretFlags manages the secret flags for this app and must be called after flag parsing and before logger init.
func initSecretFlags() {
remotewrite.InitSecretFlags()
pushmetrics.InitSecretFlags()
}

View File

@@ -52,6 +52,7 @@ func setUp() {
func tearDown() {
protoparserutil.StopUnmarshalWorkers()
remotewrite.Stop()
srv.Close()
logger.ResetOutputForTest()
tmpDataDir := flag.Lookup("remoteWrite.tmpDataPath").Value.String()

View File

@@ -156,7 +156,8 @@ var maxQueues = cgroup.AvailableCPUs() * 16
const persistentQueueDirname = "persistent-queue"
// InitSecretFlags must be called after flag.Parse and before any logging.
// InitSecretFlags manages the secret flags for this pkg and must be called by app-level initSecretFlags.
// It should run before logger initialization and package Init() (if exists).
func InitSecretFlags() {
if !*showRemoteWriteURL {
// remoteWrite.url can contain authentication codes, so hide it at `/metrics` output.
@@ -245,6 +246,8 @@ func Init() {
dropDanglingQueues()
// Start config reloader.
configReloaderStopCh = make(chan struct{})
configReloaderWG = sync.WaitGroup{}
configReloaderWG.Go(func() {
for {
select {
@@ -331,7 +334,7 @@ func initRemoteWriteCtxs(urls []string) {
}
var (
configReloaderStopCh = make(chan struct{})
configReloaderStopCh chan struct{}
configReloaderWG sync.WaitGroup
)

View File

@@ -11,6 +11,7 @@ import (
"strings"
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/netutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promauth"
@@ -267,7 +268,11 @@ func (c *Client) do(req *http.Request) (*http.Response, error) {
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return nil, fmt.Errorf("unexpected response code %d for %s. Response body %s", resp.StatusCode, ru, body)
err = &httpserver.ErrorWithStatusCode{
StatusCode: resp.StatusCode,
Err: fmt.Errorf("unexpected response code %d for %s. Response body %s", resp.StatusCode, ru, body),
}
return nil, err
}
return resp, nil
}

View File

@@ -60,7 +60,8 @@ var (
`Only valid for VictoriaMetrics as the datasource.`)
)
// InitSecretFlags must be called after flag.Parse and before any logging
// InitSecretFlags manages the secret flags for this pkg and must be called by app-level initSecretFlags.
// It should run before logger initialization and package Init() (if exists).
func InitSecretFlags() {
if !*showDatasourceURL {
flagutil.RegisterSecretFlag("datasource.url")

View File

@@ -88,10 +88,7 @@ func main() {
flag.CommandLine.SetOutput(os.Stdout)
flag.Usage = usage
envflag.Parse()
remoteread.InitSecretFlags()
remotewrite.InitSecretFlags()
datasource.InitSecretFlags()
notifier.InitSecretFlags()
initSecretFlags()
buildinfo.Init()
logger.Init()
@@ -438,3 +435,12 @@ func getLastConfigError() error {
defer lastConfigErrMu.RUnlock()
return lastConfigErr
}
// initSecretFlags manages the secret flags for this app and must be called after flag parsing and before logger init.
func initSecretFlags() {
remoteread.InitSecretFlags()
remotewrite.InitSecretFlags()
datasource.InitSecretFlags()
notifier.InitSecretFlags()
pushmetrics.InitSecretFlags()
}

View File

@@ -31,6 +31,8 @@ type Alert struct {
State AlertState
// Expr contains expression that was executed to generate the Alert
Expr string
// Interval contains the evaluation interval of the Alert's group
Interval time.Duration
// ActiveAt defines the moment of time when Alert has become active
ActiveAt time.Time
// Start defines the moment of time when Alert has become firing
@@ -84,6 +86,7 @@ type AlertTplData struct {
Labels map[string]string
Value float64
Expr string
Interval time.Duration
AlertID uint64
GroupID uint64
ActiveAt time.Time
@@ -96,6 +99,7 @@ var tplHeaders = []string{
"{{ $type := .Type }}",
"{{ $labels := .Labels }}",
"{{ $expr := .Expr }}",
"{{ $interval := .Interval }}",
"{{ $externalLabels := .ExternalLabels }}",
"{{ $externalURL := .ExternalURL }}",
"{{ $alertID := .AlertID }}",
@@ -115,6 +119,7 @@ func (a *Alert) ExecTemplate(q templates.QueryFn, labels, annotations map[string
Type: a.Type,
Labels: labels,
Expr: a.Expr,
Interval: a.Interval,
AlertID: a.ID,
GroupID: a.GroupID,
ActiveAt: a.ActiveAt,

View File

@@ -129,6 +129,17 @@ func TestAlertExecTemplate(t *testing.T) {
"exprEscapedHTML": "vm_rows{&quot;label&quot;=&quot;bar&quot;}&lt;0",
})
// interval-template
f(&Alert{
Interval: 10 * time.Second,
}, map[string]string{
"interval": "{{ .Interval }}",
"intervalVariable": "{{ $interval }}",
}, map[string]string{
"interval": "10s",
"intervalVariable": "10s",
})
// query
f(&Alert{
Expr: `vm_rows{"label"="bar"}>0`,

View File

@@ -189,7 +189,8 @@ func Init(extLabels map[string]string, extURL string) error {
return nil
}
// InitSecretFlags must be called after flag.Parse and before any logging
// InitSecretFlags manages the secret flags for this pkg and must be called by app-level initSecretFlags.
// It should run before logger initialization and package Init() (if exists).
func InitSecretFlags() {
if !*showNotifierURL {
flagutil.RegisterSecretFlag("notifier.url")

View File

@@ -56,7 +56,8 @@ var (
oauth2Scopes = flag.String("remoteRead.oauth2.scopes", "", "Optional OAuth2 scopes to use for -remoteRead.url. Scopes must be delimited by ';'.")
)
// InitSecretFlags must be called after flag.Parse and before any logging
// InitSecretFlags manages the secret flags for this pkg and must be called by app-level initSecretFlags.
// It should run before logger initialization and package Init() (if exists).
func InitSecretFlags() {
if !*showRemoteReadURL {
flagutil.RegisterSecretFlag("remoteRead.url")

View File

@@ -57,7 +57,8 @@ var (
oauth2Scopes = flag.String("remoteWrite.oauth2.scopes", "", "Optional OAuth2 scopes to use for -notifier.url. Scopes must be delimited by ';'.")
)
// InitSecretFlags must be called after flag.Parse and before any logging
// InitSecretFlags manages the secret flags for this pkg and must be called by app-level initSecretFlags.
// It should run before logger initialization and package Init() (if exists).
func InitSecretFlags() {
if !*showRemoteWriteURL {
flagutil.RegisterSecretFlag("remoteWrite.url")

View File

@@ -25,11 +25,13 @@ var (
replayMaxDatapoints = flag.Int("replay.maxDatapointsPerQuery", 1e3,
"Max number of data points expected in one request. It affects the max time range for every '/query_range' request during the replay. The higher the value, the less requests will be made during replay.")
replayRuleRetryAttempts = flag.Int("replay.ruleRetryAttempts", 5,
"Defines how many retries to make before giving up on rule if request for it returns an error.")
"Defines how many retries to make before giving up on rule if request for it returns a retriable error.")
disableProgressBar = flag.Bool("replay.disableProgressBar", false, "Whether to disable rendering progress bars during the replay. "+
"Progress bar rendering might be verbose or break the logs parsing, so it is recommended to be disabled when not used in interactive mode.")
ruleEvaluationConcurrency = flag.Int("replay.ruleEvaluationConcurrency", 1, "The maximum number of concurrent '/query_range' requests when replay recording rule or alerting rule with for=0. "+
"Increasing this value when replaying for a long time, since each request is limited by -replay.maxDatapointsPerQuery.")
continueWithExecutionErr = flag.Bool("replay.continueWithExecutionErr", false, "Whether to continue replaying other rules if a rule execution fails with a 400 or 422 response code, "+
"which can happen due to an expression syntax error or a resource limit being hit.")
)
func replay(groupsCfg []config.Group, qb datasource.QuerierBuilder, rw remotewrite.RWClient) (totalRows, droppedRows int, err error) {
@@ -73,7 +75,7 @@ func replay(groupsCfg []config.Group, qb datasource.QuerierBuilder, rw remotewri
for _, cfg := range groupsCfg {
ng := rule.NewGroup(cfg, qb, *evaluationInterval, labels)
totalRows += ng.Replay(tFrom, tTo, rw, *replayMaxDatapoints, *replayRuleRetryAttempts, *replayRulesDelay, *disableProgressBar, *ruleEvaluationConcurrency)
totalRows += ng.Replay(tFrom, tTo, rw, *replayMaxDatapoints, *replayRuleRetryAttempts, *replayRulesDelay, *disableProgressBar, *ruleEvaluationConcurrency, *continueWithExecutionErr)
}
logger.Infof("replay evaluation finished, generated %d samples", totalRows)
if err := rw.Close(); err != nil {

View File

@@ -8,8 +8,10 @@ import (
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/config"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/datasource"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prompb"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promutil"
"github.com/VictoriaMetrics/metricsql"
)
type fakeReplayQuerier struct {
@@ -32,6 +34,14 @@ func (fc *fakeRWClient) Close() error {
}
func (fr *fakeReplayQuerier) QueryRange(_ context.Context, q string, from, to time.Time) (res datasource.Result, err error) {
_, err = metricsql.Parse(q)
if err != nil {
return res, &httpserver.ErrorWithStatusCode{
StatusCode: 422,
Err: err,
}
}
key := fmt.Sprintf("%s+%s", from.Format("15:04:05"), to.Format("15:04:05"))
dps, ok := fr.registry[q]
if !ok {
@@ -275,4 +285,28 @@ func TestReplay(t *testing.T) {
},
},
}, 10)
// rule with wrong expression won't break the other rule with continueWithExecutionErr
continueWithExecutionErrOld := *continueWithExecutionErr
defer func() {
*continueWithExecutionErr = continueWithExecutionErrOld
}()
*continueWithExecutionErr = true
f("2021-01-01T12:00:00.000Z", "2021-01-01T12:02:30.000Z", 1, 1, time.Millisecond, []config.Group{
{Rules: []config.Rule{{Record: "foo", Expr: "sum(up)"}}},
{Rules: []config.Rule{{Record: "bar", Expr: "up ++"}}},
}, &fakeReplayQuerier{
registry: map[string]map[string][]datasource.Metric{
"sum(up)": {
"12:00:00+12:01:00": {
{
Timestamps: []int64{1, 2},
Values: []float64{1, 2},
},
},
"12:01:00+12:02:00": {},
"12:02:00+12:02:30": {},
},
},
}, 2)
}

View File

@@ -437,7 +437,7 @@ const resolvedRetention = 15 * time.Minute
// exec executes AlertingRule expression via the given Querier.
// Based on the Querier results AlertingRule maintains notifier.Alerts
func (ar *AlertingRule) exec(ctx context.Context, ts time.Time, limit int) ([]prompb.TimeSeries, error) {
func (ar *AlertingRule) exec(ctx context.Context, ts time.Time, limit int, getRemoteReadQuerier func(enableDebug bool) datasource.Querier) ([]prompb.TimeSeries, error) {
start := time.Now()
res, req, err := ar.q.Query(ctx, ar.Expr, ts)
curState := StateEntry{
@@ -462,7 +462,11 @@ func (ar *AlertingRule) exec(ctx context.Context, ts time.Time, limit int) ([]pr
}
isPartial := isPartialResponse(res)
ar.logDebugf(ts, nil, "query returned %d series (elapsed: %s, isPartial: %t)", curState.Samples, curState.Duration, isPartial)
seriesFetched := 0
if res.SeriesFetched != nil {
seriesFetched = *res.SeriesFetched
}
ar.logDebugf(ts, nil, "query returned %d series (series_fetched: %d, elapsed: %s, isPartial: %t)", curState.Samples, seriesFetched, curState.Duration, isPartial)
qFn := func(query string) ([]datasource.Metric, error) {
res, _, err := ar.q.Query(ctx, query, ts)
return res.Data, err
@@ -530,6 +534,7 @@ func (ar *AlertingRule) exec(ctx context.Context, ts time.Time, limit int) ([]pr
ar.logDebugf(ts, a, "INACTIVE => PENDING")
}
a.Value = m.Values[0]
a.Interval = ar.EvalInterval
a.Annotations = annotations
a.KeepFiringSince = time.Time{}
continue
@@ -541,6 +546,15 @@ func (ar *AlertingRule) exec(ctx context.Context, ts time.Time, limit int) ([]pr
ar.alerts[alertID] = a
ar.logDebugf(ts, a, "created in state PENDING")
}
// try to restore alerts state from remoteRead if necessary
if getRemoteReadQuerier != nil {
rr := getRemoteReadQuerier(ar.Debug)
err := ar.restore(ctx, rr, ts)
// do not break the current evaluation if restore request fails
if err != nil {
logger.Errorf("error while restoring ruleState for group %q(file %q) rule %q: %s", ar.GroupName, ar.File, ar.Name, err)
}
}
var numActivePending int
var tss []prompb.TimeSeries
for h, a := range ar.alerts {
@@ -612,6 +626,7 @@ func (ar *AlertingRule) expandAnnotationTemplates(m datasource.Metric, qFn templ
Type: ar.Type.String(),
Labels: ls.origin,
Expr: ar.Expr,
Interval: ar.EvalInterval,
AlertID: hash(ls.processed),
GroupID: ar.GroupID,
ActiveAt: activeAt,
@@ -673,6 +688,7 @@ func (ar *AlertingRule) newAlert(m datasource.Metric, start time.Time, labels, a
Name: ar.Name,
Type: ar.Type.String(),
Expr: ar.Expr,
Interval: ar.EvalInterval,
For: ar.For,
ActiveAt: start,
Value: m.Values[0],
@@ -792,7 +808,7 @@ func firingAlertStaleTimeSeries(ls map[string]string, timestamp int64) []prompb.
// restore restores the value of ActiveAt field for active alerts,
// based on previously written time series `alertForStateMetricName`.
// Only rules with For > 0 can be restored.
func (ar *AlertingRule) restore(ctx context.Context, q datasource.Querier, ts time.Time, lookback time.Duration) error {
func (ar *AlertingRule) restore(ctx context.Context, q datasource.Querier, ts time.Time) error {
if ar.For < 1 {
return nil
}
@@ -818,11 +834,9 @@ func (ar *AlertingRule) restore(ctx context.Context, q datasource.Querier, ts ti
}
// use `default_rollup()` instead of `last_over_time()` here to accounts for possible staleness markers
expr := fmt.Sprintf("default_rollup(%s{%s%s}[%ds])",
alertForStateMetricName, nameStr, labelsFilter, int(lookback.Seconds()))
alertForStateMetricName, nameStr, labelsFilter, int(remoteReadLookBack.Seconds()))
// query ALERTS_FOR_STATE at `ts-1s` instead `ts` to avoid retrieving data written in the current run,
// see https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10335
res, _, err := q.Query(ctx, expr, ts.Add(-1*time.Second))
res, _, err := q.Query(ctx, expr, ts)
if err != nil {
return fmt.Errorf("failed to execute restore query %q: %w ", expr, err)
}
@@ -832,9 +846,6 @@ func (ar *AlertingRule) restore(ctx context.Context, q datasource.Querier, ts ti
return nil
}
ar.alertsMu.Lock()
defer ar.alertsMu.Unlock()
for _, series := range res.Data {
series.DelLabel("__name__")
labelSet := make(map[string]string, len(series.Labels))

View File

@@ -44,7 +44,7 @@ func TestAlertingRule_ActiveAtPreservedInAnnotations(t *testing.T) {
// First execution - creates new alert
ts1 := time.Now()
_, err := ar.exec(context.TODO(), ts1, 0)
_, err := ar.exec(context.TODO(), ts1, 0, nil)
if err != nil {
t.Fatalf("unexpected error on first exec: %s", err)
}
@@ -71,7 +71,7 @@ func TestAlertingRule_ActiveAtPreservedInAnnotations(t *testing.T) {
// sleep is non-blocking thanks to synctest
time.Sleep(2 * time.Second)
ts2 := time.Now()
_, err = ar.exec(context.TODO(), ts2, 0)
_, err = ar.exec(context.TODO(), ts2, 0, nil)
if err != nil {
t.Fatalf("unexpected error on second exec: %s", err)
}

View File

@@ -229,7 +229,7 @@ func TestAlertingRule_Exec(t *testing.T) {
for i, step := range steps {
fq.Reset()
fq.Add(step...)
tss, err := rule.exec(context.TODO(), ts, 0)
tss, err := rule.exec(context.TODO(), ts, 0, nil)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
@@ -662,6 +662,7 @@ func TestAlertingRuleExecRange(t *testing.T) {
GroupID: fakeGroup.GetID(),
Name: "for-pending",
Type: config.NewPrometheusType().String(),
Interval: time.Second,
Labels: map[string]string{"alertname": "for-pending"},
Annotations: map[string]string{},
State: notifier.StatePending,
@@ -682,6 +683,7 @@ func TestAlertingRuleExecRange(t *testing.T) {
GroupID: fakeGroup.GetID(),
Name: "for-firing",
Type: config.NewPrometheusType().String(),
Interval: 3 * time.Second,
Labels: map[string]string{"alertname": "for-firing"},
Annotations: map[string]string{},
State: notifier.StateFiring,
@@ -703,6 +705,7 @@ func TestAlertingRuleExecRange(t *testing.T) {
GroupID: fakeGroup.GetID(),
Name: "for-hold-pending",
Type: config.NewPrometheusType().String(),
Interval: time.Second,
Labels: map[string]string{"alertname": "for-hold-pending"},
Annotations: map[string]string{},
State: notifier.StatePending,
@@ -759,6 +762,7 @@ func TestAlertingRuleExecRange(t *testing.T) {
GroupID: fakeGroup.GetID(),
Name: "multi-series",
Type: config.NewPrometheusType().String(),
Interval: 3 * time.Second,
Labels: map[string]string{"alertname": "multi-series"},
Annotations: map[string]string{},
State: notifier.StateFiring,
@@ -771,6 +775,7 @@ func TestAlertingRuleExecRange(t *testing.T) {
GroupID: fakeGroup.GetID(),
Name: "multi-series",
Type: config.NewPrometheusType().String(),
Interval: 3 * time.Second,
Labels: map[string]string{"alertname": "multi-series", "foo": "bar"},
Annotations: map[string]string{},
State: notifier.StatePending,
@@ -819,10 +824,11 @@ func TestAlertingRuleExecRange(t *testing.T) {
func TestGroup_Restore(t *testing.T) {
defaultTS := time.Now()
fqr := &datasource.FakeQuerierWithRegistry{}
fn := func(rules []config.Rule, expAlerts map[uint64]*notifier.Alert) {
f := func(rules []config.Rule, expAlerts map[uint64]*notifier.Alert, expNotificationNum int) {
t.Helper()
defer fqr.Reset()
fn, cleanup := notifier.InitFakeNotifier()
defer cleanup()
fg := NewGroup(config.Group{Name: "TestRestore", Rules: rules}, fqr, time.Second, nil)
fg.Init()
wg := sync.WaitGroup{}
@@ -852,8 +858,8 @@ func TestGroup_Restore(t *testing.T) {
if !ok {
t.Fatalf("expected to have key %d", key)
}
if got.State != notifier.StatePending {
t.Fatalf("expected state %d; got %d", notifier.StatePending, got.State)
if got.State != exp.State {
t.Fatalf("expected state %d; got %d", exp.State, got.State)
}
if got.ActiveAt != exp.ActiveAt {
t.Fatalf("expected ActiveAt %v; got %v", exp.ActiveAt, got.ActiveAt)
@@ -862,6 +868,9 @@ func TestGroup_Restore(t *testing.T) {
t.Fatalf("expected alertname %q; got %q", exp.Name, got.Name)
}
}
if fn.GetCounter() != expNotificationNum {
t.Fatalf("expected %d notifications; got %d", expNotificationNum, fn.GetCounter())
}
}
stateMetric := func(name string, value time.Time, labels ...string) datasource.Metric {
@@ -873,28 +882,30 @@ func TestGroup_Restore(t *testing.T) {
// one active alert, no previous state
fqr.Set("foo", metricWithValueAndLabels(t, 0, "__name__", "foo"))
fn(
f(
[]config.Rule{{Alert: "foo", Expr: "foo", For: promutil.NewDuration(time.Second)}},
map[uint64]*notifier.Alert{
hash(map[string]string{alertNameLabel: "foo", alertGroupNameLabel: "TestRestore"}): {
Name: "foo",
ActiveAt: defaultTS,
State: notifier.StatePending,
},
})
}, 0)
// one active alert with state restore
ts := time.Now().Truncate(time.Hour)
fqr.Set("foo", metricWithValueAndLabels(t, 0, "__name__", "foo"))
fqr.Set(`default_rollup(ALERTS_FOR_STATE{alertgroup="TestRestore",alertname="foo"}[3600s])`,
stateMetric("foo", ts))
fn(
f(
[]config.Rule{{Alert: "foo", Expr: "foo", For: promutil.NewDuration(time.Second)}},
map[uint64]*notifier.Alert{
hash(map[string]string{alertNameLabel: "foo", alertGroupNameLabel: "TestRestore"}): {
Name: "foo",
ActiveAt: ts,
State: notifier.StateFiring,
},
})
}, 1)
// one rule, two active alerts, one with state restored
ts = time.Now().Truncate(time.Hour)
@@ -904,7 +915,7 @@ func TestGroup_Restore(t *testing.T) {
fqr.Set(`default_rollup(ALERTS_FOR_STATE{alertgroup="TestRestore",alertname="foo"}[3600s])`,
// only env=prod has state metric, so only it will have state restore
stateMetric("foo", ts, "env", "prod"))
fn(
f(
[]config.Rule{
{Alert: "foo", Expr: "foo", For: promutil.NewDuration(time.Second)},
},
@@ -912,12 +923,14 @@ func TestGroup_Restore(t *testing.T) {
hash(map[string]string{alertNameLabel: "foo", alertGroupNameLabel: "TestRestore", "env": "dev"}): {
Name: "foo",
ActiveAt: defaultTS,
State: notifier.StatePending,
},
hash(map[string]string{alertNameLabel: "foo", alertGroupNameLabel: "TestRestore", "env": "prod"}): {
Name: "foo",
ActiveAt: ts,
State: notifier.StateFiring,
},
})
}, 1)
// two rules, two active alerts, one with state restored
ts = time.Now().Truncate(time.Hour)
@@ -925,7 +938,7 @@ func TestGroup_Restore(t *testing.T) {
fqr.Set("bar", metricWithValueAndLabels(t, 0, "__name__", "bar"))
fqr.Set(`default_rollup(ALERTS_FOR_STATE{alertgroup="TestRestore",alertname="bar"}[3600s])`,
stateMetric("bar", ts))
fn(
f(
[]config.Rule{
{Alert: "foo", Expr: "foo", For: promutil.NewDuration(time.Second)},
{Alert: "bar", Expr: "bar", For: promutil.NewDuration(time.Second)},
@@ -934,12 +947,14 @@ func TestGroup_Restore(t *testing.T) {
hash(map[string]string{alertNameLabel: "foo", alertGroupNameLabel: "TestRestore"}): {
Name: "foo",
ActiveAt: defaultTS,
State: notifier.StatePending,
},
hash(map[string]string{alertNameLabel: "bar", alertGroupNameLabel: "TestRestore"}): {
Name: "bar",
ActiveAt: ts,
State: notifier.StateFiring,
},
})
}, 1)
// two rules, two active alerts, two with state restored
ts = time.Now().Truncate(time.Hour)
@@ -949,63 +964,68 @@ func TestGroup_Restore(t *testing.T) {
stateMetric("foo", ts))
fqr.Set(`default_rollup(ALERTS_FOR_STATE{alertgroup="TestRestore",alertname="bar"}[3600s])`,
stateMetric("bar", ts))
fn(
f(
[]config.Rule{
{Alert: "foo", Expr: "foo", For: promutil.NewDuration(time.Second)},
{Alert: "bar", Expr: "bar", For: promutil.NewDuration(time.Second)},
{Alert: "bar", Expr: "bar", For: promutil.NewDuration(time.Hour)},
},
map[uint64]*notifier.Alert{
hash(map[string]string{alertNameLabel: "foo", alertGroupNameLabel: "TestRestore"}): {
Name: "foo",
ActiveAt: ts,
State: notifier.StateFiring,
},
hash(map[string]string{alertNameLabel: "bar", alertGroupNameLabel: "TestRestore"}): {
Name: "bar",
ActiveAt: ts,
State: notifier.StatePending,
},
})
}, 1)
// one active alert but wrong state restore
ts = time.Now().Truncate(time.Hour)
fqr.Set("foo", metricWithValueAndLabels(t, 0, "__name__", "foo"))
fqr.Set(`default_rollup(ALERTS_FOR_STATE{alertname="bar",alertgroup="TestRestore"}[3600s])`,
stateMetric("wrong alert", ts))
fn(
f(
[]config.Rule{{Alert: "foo", Expr: "foo", For: promutil.NewDuration(time.Second)}},
map[uint64]*notifier.Alert{
hash(map[string]string{alertNameLabel: "foo", alertGroupNameLabel: "TestRestore"}): {
Name: "foo",
ActiveAt: defaultTS,
State: notifier.StatePending,
},
})
}, 0)
// one active alert with labels
ts = time.Now().Truncate(time.Hour)
fqr.Set("foo", metricWithValueAndLabels(t, 0, "__name__", "foo"))
fqr.Set(`default_rollup(ALERTS_FOR_STATE{alertgroup="TestRestore",alertname="foo",env="dev"}[3600s])`,
stateMetric("foo", ts, "env", "dev"))
fn(
f(
[]config.Rule{{Alert: "foo", Expr: "foo", Labels: map[string]string{"env": "dev"}, For: promutil.NewDuration(time.Second)}},
map[uint64]*notifier.Alert{
hash(map[string]string{alertNameLabel: "foo", alertGroupNameLabel: "TestRestore", "env": "dev"}): {
Name: "foo",
ActiveAt: ts,
State: notifier.StateFiring,
},
})
}, 1)
// one active alert with restore labels mismatch
ts = time.Now().Truncate(time.Hour)
fqr.Set("foo", metricWithValueAndLabels(t, 0, "__name__", "foo"))
fqr.Set(`default_rollup(ALERTS_FOR_STATE{alertgroup="TestRestore",alertname="foo",env="dev"}[3600s])`,
stateMetric("foo", ts, "env", "dev", "team", "foo"))
fn(
f(
[]config.Rule{{Alert: "foo", Expr: "foo", Labels: map[string]string{"env": "dev"}, For: promutil.NewDuration(time.Second)}},
map[uint64]*notifier.Alert{
hash(map[string]string{alertNameLabel: "foo", alertGroupNameLabel: "TestRestore", "env": "dev"}): {
Name: "foo",
ActiveAt: defaultTS,
State: notifier.StatePending,
},
})
}, 0)
// two active alerts with dynamic labels and restore
ts = time.Now().Truncate(time.Hour)
@@ -1015,18 +1035,20 @@ func TestGroup_Restore(t *testing.T) {
fqr.Set("foo",
metricWithValueAndLabels(t, 0, "__name__", "foo", "env", "dev"),
metricWithValueAndLabels(t, 0, "__name__", "foo", "env", "prod"))
fn(
f(
[]config.Rule{{Alert: "foo", Expr: "foo", Labels: map[string]string{"env": "{{$labels.env}}"}, For: promutil.NewDuration(time.Second)}},
map[uint64]*notifier.Alert{
hash(map[string]string{alertNameLabel: "foo", alertGroupNameLabel: "TestRestore", "env": "dev"}): {
Name: "foo",
ActiveAt: ts,
State: notifier.StateFiring,
},
hash(map[string]string{alertNameLabel: "foo", alertGroupNameLabel: "TestRestore", "env": "prod"}): {
Name: "foo",
ActiveAt: ts.Add(time.Second),
State: notifier.StateFiring,
},
})
}, 2)
}
func TestAlertingRule_Exec_Negative(t *testing.T) {
@@ -1039,14 +1061,14 @@ func TestAlertingRule_Exec_Negative(t *testing.T) {
// label `job` will be overridden by rule extra label, the original value will be reserved by "exported_job"
fq.Add(metricWithValueAndLabels(t, 1, "__name__", "foo", "job", "bar"))
fq.Add(metricWithValueAndLabels(t, 1, "__name__", "foo", "job", "baz"))
_, err := ar.exec(context.TODO(), time.Now(), 0)
_, err := ar.exec(context.TODO(), time.Now(), 0, nil)
if err != nil {
t.Fatal(err)
}
// label `__name__` will be omitted and get duplicated results here
fq.Add(metricWithValueAndLabels(t, 1, "__name__", "foo_1", "job", "bar"))
_, err = ar.exec(context.TODO(), time.Now(), 0)
_, err = ar.exec(context.TODO(), time.Now(), 0, nil)
if !errors.Is(err, errDuplicate) {
t.Fatalf("expected to have %s error; got %s", errDuplicate, err)
}
@@ -1055,7 +1077,7 @@ func TestAlertingRule_Exec_Negative(t *testing.T) {
expErr := "connection reset by peer"
fq.SetErr(errors.New(expErr))
_, err = ar.exec(context.TODO(), time.Now(), 0)
_, err = ar.exec(context.TODO(), time.Now(), 0, nil)
if err == nil {
t.Fatalf("expected to get err; got nil")
}
@@ -1078,7 +1100,7 @@ func TestAlertingRuleLimit_Failure(t *testing.T) {
fq.Add(metricWithValueAndLabels(t, 1, "__name__", "foo", "bar", "job"))
timestamp := time.Now()
_, err := ar.exec(context.TODO(), timestamp, limit)
_, err := ar.exec(context.TODO(), timestamp, limit, nil)
if err == nil {
t.Fatalf("expecting non-nil error")
}
@@ -1106,7 +1128,7 @@ func TestAlertingRuleLimit_Success(t *testing.T) {
fq.Add(metricWithValueAndLabels(t, 1, "__name__", "foo", "bar", "job"))
timestamp := time.Now()
_, err := ar.exec(context.TODO(), timestamp, limit)
_, err := ar.exec(context.TODO(), timestamp, limit, nil)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
@@ -1134,7 +1156,8 @@ func TestAlertingRule_Template(t *testing.T) {
fq.Add(metrics...)
fq.SetPartialResponse(isResponsePartial)
if _, err := rule.exec(context.TODO(), time.Now(), 0); err != nil {
ts := time.Unix(3600, 0)
if _, err := rule.exec(context.TODO(), ts, 0, nil); err != nil {
t.Fatalf("unexpected error: %s", err)
}
for hash, expAlert := range alertsExpected {
@@ -1152,12 +1175,14 @@ func TestAlertingRule_Template(t *testing.T) {
}
f(&AlertingRule{
Name: "common",
Name: "common",
EvalInterval: time.Hour,
Labels: map[string]string{
"region": "east",
},
Annotations: map[string]string{
"summary": `{{ $labels.alertname }}: Too high connection number for "{{ $labels.instance }}"`,
"summary": `{{ $labels.alertname }}: Too high connection number for "{{ $labels.instance }}"`,
"dashboard": `&from={{ ($activeAt.Add (parseDurationTime (printf "-%s" .Interval))).UnixMilli }}&to={{ $activeAt.UnixMilli }}`,
},
alerts: make(map[uint64]*notifier.Alert),
}, []datasource.Metric{
@@ -1166,7 +1191,8 @@ func TestAlertingRule_Template(t *testing.T) {
}, false, map[uint64]*notifier.Alert{
hash(map[string]string{alertNameLabel: "common", "region": "east", "instance": "foo"}): {
Annotations: map[string]string{
"summary": `common: Too high connection number for "foo"`,
"summary": `common: Too high connection number for "foo"`,
"dashboard": "&from=0&to=3600000",
},
Labels: map[string]string{
alertNameLabel: "common",
@@ -1176,7 +1202,8 @@ func TestAlertingRule_Template(t *testing.T) {
},
hash(map[string]string{alertNameLabel: "common", "region": "east", "instance": "bar"}): {
Annotations: map[string]string{
"summary": `common: Too high connection number for "bar"`,
"summary": `common: Too high connection number for "bar"`,
"dashboard": "&from=0&to=3600000",
},
Labels: map[string]string{
alertNameLabel: "common",
@@ -1388,7 +1415,7 @@ func TestAlertingRule_ToLabels(t *testing.T) {
"alertname": "ConfigurationReloadFailure",
"alertgroup": "vmalert",
"pod": "vmalert-0",
"invalid_label": `error evaluating template: template: :1:298: executing "" at <.Values.mustRuntimeFail>: can't evaluate field Values in type notifier.tplData`,
"invalid_label": `error evaluating template: template: :1:326: executing "" at <.Values.mustRuntimeFail>: can't evaluate field Values in type notifier.tplData`,
}
expectedProcessedLabels := map[string]string{
@@ -1398,7 +1425,7 @@ func TestAlertingRule_ToLabels(t *testing.T) {
"exported_alertname": "ConfigurationReloadFailure",
"group": "vmalert",
"alertgroup": "vmalert",
"invalid_label": `error evaluating template: template: :1:298: executing "" at <.Values.mustRuntimeFail>: can't evaluate field Values in type notifier.tplData`,
"invalid_label": `error evaluating template: template: :1:326: executing "" at <.Values.mustRuntimeFail>: can't evaluate field Values in type notifier.tplData`,
}
ls, err := ar.toLabels(metric, nil)
@@ -1429,7 +1456,7 @@ func TestAlertingRuleExec_Partial(t *testing.T) {
fq.Add(metricWithValueAndLabels(t, 1, "__name__", "foo", "job", "bar"))
ts := time.Now()
_, err := ar.exec(context.TODO(), ts, 0)
_, err := ar.exec(context.TODO(), ts, 0, nil)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
@@ -1461,7 +1488,7 @@ func TestAlertingRule_QueryTemplateInLabels(t *testing.T) {
fq.Add(metricWithValueAndLabels(t, 1, "device", "sda1"))
ts := time.Now()
_, err := ar.exec(context.TODO(), ts, 0)
_, err := ar.exec(context.TODO(), ts, 0, nil)
if err != nil {
t.Fatalf("unexpected error with query template in labels: %s", err)
}

View File

@@ -222,29 +222,6 @@ func (g *Group) CreateID() uint64 {
return hash.Sum64()
}
// restore restores alerts state for group rules
func (g *Group) restore(ctx context.Context, qb datasource.QuerierBuilder, ts time.Time, lookback time.Duration) error {
for _, rule := range g.Rules {
ar, ok := rule.(*AlertingRule)
if !ok {
continue
}
if ar.For < 1 {
continue
}
q := qb.BuildWithParams(datasource.QuerierParams{
EvaluationInterval: g.Interval,
QueryParams: g.Params,
Headers: g.Headers,
Debug: ar.Debug,
})
if err := ar.restore(ctx, q, ts, lookback); err != nil {
return fmt.Errorf("error while restoring rule %q: %w", rule, err)
}
}
return nil
}
// updateWith updates existing group with
// passed group object. This function ignores group
// evaluation interval change. It supposed to be updated
@@ -290,6 +267,8 @@ func (g *Group) updateWith(newGroup *Group) error {
g.Headers = newGroup.Headers
g.NotifierHeaders = newGroup.NotifierHeaders
g.Labels = newGroup.Labels
g.EvalDelay = newGroup.EvalDelay
g.evalAlignment = newGroup.evalAlignment
g.Limit = newGroup.Limit
g.checksum = newGroup.checksum
g.Rules = newRules
@@ -337,7 +316,7 @@ func (g *Group) Init() {
i := g.Interval.Seconds()
return i
})
g.metrics.iterationLimit = g.metrics.set.NewGauge(fmt.Sprintf(`vmalert_rule_group_results_limit{%s}`, labels), func() float64 {
g.metrics.iterationLimit = g.metrics.set.NewGauge(fmt.Sprintf(`vmalert_group_rule_results_limit{%s}`, labels), func() float64 {
g.mu.RLock()
limit := g.Limit
g.mu.RUnlock()
@@ -373,7 +352,7 @@ func (g *Group) Start(ctx context.Context, rw remotewrite.RWClient, rr datasourc
g.mu.Lock()
err := g.updateWith(ng)
if err != nil {
logger.Errorf("group %q: failed to update: %s", g.Name, err)
logger.Errorf("group %q (file=%q): failed to update: %s", g.Name, g.File, err)
g.mu.Unlock()
continue
}
@@ -393,7 +372,7 @@ func (g *Group) Start(ctx context.Context, rw remotewrite.RWClient, rr datasourc
g.infof("started")
eval := func(ctx context.Context, ts time.Time) time.Time {
eval := func(ctx context.Context, ts time.Time, getRemoteReadQuerier func(enableDebug bool) datasource.Querier) {
g.metrics.iterationTotal.Inc()
start := time.Now()
@@ -403,23 +382,22 @@ func (g *Group) Start(ctx context.Context, rw remotewrite.RWClient, rr datasourc
g.mu.Lock()
g.LastEvaluation = start
g.mu.Unlock()
return ts
return
}
resolveDuration := getResolveDuration(g.Interval, *resendDelay, *maxResolveDuration)
// adjust request timestamp using evalDelay and evalAlignment if necessary
ts = g.adjustReqTimestamp(ts)
errs := e.execConcurrently(ctx, g.Rules, ts, g.Concurrency, resolveDuration, g.Limit)
errs := e.execConcurrently(ctx, g.Rules, ts, g.Concurrency, resolveDuration, g.Limit, getRemoteReadQuerier)
for err := range errs {
if err != nil {
logger.Errorf("group %q: %s", g.Name, err)
logger.Errorf("group %q (file=%q): %s", g.Name, g.File, err)
}
}
g.metrics.iterationDuration.UpdateDuration(start)
g.mu.Lock()
g.LastEvaluation = start
g.mu.Unlock()
return ts
}
evalCtx, cancel := context.WithCancel(ctx)
@@ -434,24 +412,27 @@ func (g *Group) Start(ctx context.Context, rw remotewrite.RWClient, rr datasourc
t := time.NewTicker(g.Interval)
defer t.Stop()
realEvalTS := eval(evalCtx, evalTS)
// restore the rules state after the first evaluation
// so only active alerts can be restored.
var getRemoteReadQuerier func(enableDebug bool) datasource.Querier
if rr != nil {
err := g.restore(ctx, rr, realEvalTS, *remoteReadLookBack)
if err != nil {
logger.Errorf("error while restoring ruleState for group %q: %s", g.Name, err)
getRemoteReadQuerier = func(enableDebug bool) datasource.Querier {
return rr.BuildWithParams(datasource.QuerierParams{
EvaluationInterval: g.Interval,
QueryParams: g.Params,
Headers: g.Headers,
Debug: enableDebug,
})
}
}
// pass getRemoteReadQuerier to the first evaluation, so it can be used for restoring alert states
eval(evalCtx, evalTS, getRemoteReadQuerier)
for {
select {
case <-ctx.Done():
logger.Infof("group %q: context cancelled", g.Name)
logger.Infof("group %q (file=%q): context cancelled", g.Name, g.File)
return
case <-g.doneCh:
logger.Infof("group %q: received stop signal", g.Name)
logger.Infof("group %q (file=%q): received stop signal", g.Name, g.File)
return
case ng := <-g.updateCh:
g.mu.Lock()
@@ -465,7 +446,7 @@ func (g *Group) Start(ctx context.Context, rw remotewrite.RWClient, rr datasourc
err := g.updateWith(ng)
if err != nil {
logger.Errorf("group %q: failed to update: %s", g.Name, err)
logger.Errorf("group %q (file=%q): failed to update: %s", g.Name, g.File, err)
g.mu.Unlock()
continue
}
@@ -494,7 +475,7 @@ func (g *Group) Start(ctx context.Context, rw remotewrite.RWClient, rr datasourc
g.metrics.iterationMissed.Inc()
}
eval(evalCtx, evalTS)
eval(evalCtx, evalTS, nil)
}
}
}
@@ -543,12 +524,12 @@ func (g *Group) delayBeforeStart(ts time.Time, maxDelay time.Duration) time.Dura
func (g *Group) infof(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
logger.Infof("group %q %s; interval=%v; eval_offset=%v; concurrency=%d",
g.Name, msg, g.Interval, g.EvalOffset, g.Concurrency)
logger.Infof("group %q (file=%q; interval=%v; eval_offset=%v; concurrency=%d) %s",
g.Name, g.File, g.Interval, g.EvalOffset, g.Concurrency, msg)
}
// Replay performs group replay
func (g *Group) Replay(start, end time.Time, rw remotewrite.RWClient, maxDataPoint, replayRuleRetryAttempts int, replayDelay time.Duration, disableProgressBar bool, ruleEvaluationConcurrency int) int {
func (g *Group) Replay(start, end time.Time, rw remotewrite.RWClient, maxDataPoint, replayRuleRetryAttempts int, replayDelay time.Duration, disableProgressBar bool, ruleEvaluationConcurrency int, continueWithExecutionErr bool) int {
var total int
step := g.Interval * time.Duration(maxDataPoint)
ri := rangeIterator{start: start, end: end, step: step}
@@ -576,7 +557,7 @@ func (g *Group) Replay(start, end time.Time, rw remotewrite.RWClient, maxDataPoi
if !disableProgressBar {
bar = pb.StartNew(iterations)
}
total += replayRuleRange(rule, ri, bar, rw, replayRuleRetryAttempts, ruleEvaluationConcurrency)
total += replayRuleRange(rule, ri, bar, rw, replayRuleRetryAttempts, ruleEvaluationConcurrency, continueWithExecutionErr)
if bar != nil {
bar.Finish()
}
@@ -598,7 +579,7 @@ func (g *Group) Replay(start, end time.Time, rw remotewrite.RWClient, maxDataPoi
rule := g.Rules[i]
sem <- struct{}{}
wg.Go(func() {
res <- replayRuleRange(rule, ri, bar, rw, replayRuleRetryAttempts, ruleEvaluationConcurrency)
res <- replayRuleRange(rule, ri, bar, rw, replayRuleRetryAttempts, ruleEvaluationConcurrency, continueWithExecutionErr)
<-sem
})
}
@@ -618,7 +599,7 @@ func (g *Group) Replay(start, end time.Time, rw remotewrite.RWClient, maxDataPoi
return total
}
func replayRuleRange(r Rule, ri rangeIterator, bar *pb.ProgressBar, rw remotewrite.RWClient, replayRuleRetryAttempts, ruleEvaluationConcurrency int) int {
func replayRuleRange(r Rule, ri rangeIterator, bar *pb.ProgressBar, rw remotewrite.RWClient, replayRuleRetryAttempts, ruleEvaluationConcurrency int, continueWithExecutionErr bool) int {
fmt.Printf("> Rule %q (ID: %d)\n", r, r.ID())
// alerting rule with for>0 can't be replayed concurrently, since the status change might depend on the previous evaluation
// see https://github.com/VictoriaMetrics/VictoriaMetrics/commit/abcb21aa5ee918ba9a4e9cde495dba06e1e9564c
@@ -633,7 +614,7 @@ func replayRuleRange(r Rule, ri rangeIterator, bar *pb.ProgressBar, rw remotewri
start := ri.s
end := ri.e
wg.Go(func() {
n, err := replayRule(r, start, end, rw, replayRuleRetryAttempts)
n, err := replayRule(r, start, end, rw, replayRuleRetryAttempts, continueWithExecutionErr)
if err != nil {
logger.Fatalf("rule %q: %s", r, err)
}
@@ -665,7 +646,7 @@ func (g *Group) ExecOnce(ctx context.Context, rw remotewrite.RWClient, evalTS ti
return nil
}
resolveDuration := getResolveDuration(g.Interval, *resendDelay, *maxResolveDuration)
return e.execConcurrently(ctx, g.Rules, evalTS, g.Concurrency, resolveDuration, g.Limit)
return e.execConcurrently(ctx, g.Rules, evalTS, g.Concurrency, resolveDuration, g.Limit, nil)
}
type rangeIterator struct {
@@ -739,12 +720,12 @@ type executor struct {
}
// execConcurrently executes rules concurrently if concurrency>1
func (e *executor) execConcurrently(ctx context.Context, rules []Rule, ts time.Time, concurrency int, resolveDuration time.Duration, limit int) chan error {
func (e *executor) execConcurrently(ctx context.Context, rules []Rule, ts time.Time, concurrency int, resolveDuration time.Duration, limit int, getRemoteReadQuerier func(enableDebug bool) datasource.Querier) chan error {
res := make(chan error, len(rules))
if concurrency == 1 {
// fast path
for _, rule := range rules {
res <- e.exec(ctx, rule, ts, resolveDuration, limit)
res <- e.exec(ctx, rule, ts, resolveDuration, limit, getRemoteReadQuerier)
}
close(res)
return res
@@ -757,7 +738,7 @@ func (e *executor) execConcurrently(ctx context.Context, rules []Rule, ts time.T
rule := rules[i]
sem <- struct{}{}
wg.Go(func() {
res <- e.exec(ctx, rule, ts, resolveDuration, limit)
res <- e.exec(ctx, rule, ts, resolveDuration, limit, getRemoteReadQuerier)
<-sem
})
}
@@ -774,10 +755,10 @@ var (
execErrors = metrics.NewCounter(`vmalert_execution_errors_total`)
)
func (e *executor) exec(ctx context.Context, r Rule, ts time.Time, resolveDuration time.Duration, limit int) error {
func (e *executor) exec(ctx context.Context, r Rule, ts time.Time, resolveDuration time.Duration, limit int, getRemoteReadQuerier func(enableDebug bool) datasource.Querier) error {
execTotal.Inc()
tss, err := r.exec(ctx, ts, limit)
tss, err := r.exec(ctx, ts, limit, getRemoteReadQuerier)
if err != nil {
if errors.Is(err, context.Canceled) {
// the context can be cancelled on graceful shutdown

View File

@@ -78,6 +78,12 @@ func TestUpdateWith(t *testing.T) {
if g.Debug != expect.Debug {
t.Fatalf("expected to have debug %v; got %v", expect.Debug, g.Debug)
}
if !durationPtrEqual(g.EvalDelay, expect.EvalDelay) {
t.Fatalf("expected to have eval_delay %v; got %v", expect.EvalDelay, g.EvalDelay)
}
if !boolPtrEqual(g.evalAlignment, expect.evalAlignment) {
t.Fatalf("expected to have eval_alignment %v; got %v", expect.evalAlignment, g.evalAlignment)
}
}
// new rule
@@ -237,6 +243,37 @@ func TestUpdateWith(t *testing.T) {
{Alert: "foo1", Debug: &debug},
},
})
// update group evaluation settings
evalDelay := promutil.NewDuration(time.Minute)
evalAlignment := false
f(config.Group{
Rules: []config.Rule{{
Record: "foo",
Expr: "max(up)",
}},
}, config.Group{
EvalDelay: evalDelay,
EvalAlignment: &evalAlignment,
Rules: []config.Rule{{
Record: "foo",
Expr: "min(up)",
}},
})
}
func durationPtrEqual(a, b *time.Duration) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
func boolPtrEqual(a, b *bool) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
func TestUpdateDuringRandSleep(t *testing.T) {
@@ -484,7 +521,7 @@ func TestFaultyNotifier(t *testing.T) {
defer cancel()
go func() {
_ = e.exec(ctx, r, time.Now(), 0, 10)
_ = e.exec(ctx, r, time.Now(), 0, 10, nil)
}()
tn := time.Now()
@@ -516,7 +553,7 @@ func TestFaultyRW(t *testing.T) {
Rw: &remotewrite.Client{},
}
err := e.exec(context.Background(), r, time.Now(), 0, 10)
err := e.exec(context.Background(), r, time.Now(), 0, 10, nil)
if err == nil {
t.Fatalf("expected to get an error from faulty RW client, got nil instead")
}

View File

@@ -184,7 +184,7 @@ func (rr *RecordingRule) execRange(ctx context.Context, start, end time.Time) ([
}
// exec executes RecordingRule expression via the given Querier.
func (rr *RecordingRule) exec(ctx context.Context, ts time.Time, limit int) ([]prompb.TimeSeries, error) {
func (rr *RecordingRule) exec(ctx context.Context, ts time.Time, limit int, _ func(enableDebug bool) datasource.Querier) ([]prompb.TimeSeries, error) {
start := time.Now()
res, req, err := rr.q.Query(ctx, rr.Expr, ts)
curState := StateEntry{
@@ -208,7 +208,11 @@ func (rr *RecordingRule) exec(ctx context.Context, ts time.Time, limit int) ([]p
return nil, curState.Err
}
rr.logDebugf(ts, "query returned %d samples (elapsed: %s, isPartial: %t)", curState.Samples, curState.Duration, isPartialResponse(res))
seriesFetched := 0
if res.SeriesFetched != nil {
seriesFetched = *res.SeriesFetched
}
rr.logDebugf(ts, "query returned %d samples (series_fetched: %d, elapsed: %s, isPartial: %t)", curState.Samples, seriesFetched, curState.Duration, isPartialResponse(res))
qMetrics := res.Data
numSeries := len(qMetrics)

View File

@@ -52,7 +52,7 @@ func TestRecordingRule_Exec(t *testing.T) {
rule.state = &ruleState{
entries: make([]StateEntry, 10),
}
tss, err := rule.exec(context.TODO(), ts, 0)
tss, err := rule.exec(context.TODO(), ts, 0, nil)
if err != nil {
t.Fatalf("fail to test rule %s: unexpected error: %s", rule.Name, err)
}
@@ -358,7 +358,7 @@ func TestRecordingRuleLimit_Failure(t *testing.T) {
}
rule.q = fq
_, err := rule.exec(context.TODO(), time.Now(), limit)
_, err := rule.exec(context.TODO(), time.Now(), limit, nil)
if err == nil {
t.Fatalf("expecting non-nil error")
}
@@ -394,7 +394,7 @@ func TestRecordingRuleLimit_Success(t *testing.T) {
}
rule.q = fq
_, err := rule.exec(context.TODO(), time.Now(), limit)
_, err := rule.exec(context.TODO(), time.Now(), limit, nil)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
@@ -422,7 +422,7 @@ func TestRecordingRuleExec_Negative(t *testing.T) {
expErr := "connection reset by peer"
fq.SetErr(errors.New(expErr))
rr.q = fq
_, err := rr.exec(context.TODO(), time.Now(), 0)
_, err := rr.exec(context.TODO(), time.Now(), 0, nil)
if err == nil {
t.Fatalf("expected to get err; got nil")
}
@@ -437,7 +437,7 @@ func TestRecordingRuleExec_Negative(t *testing.T) {
fq.Add(metricWithValueAndLabels(t, 1, "__name__", "foo", "job", "foo"))
fq.Add(metricWithValueAndLabels(t, 2, "__name__", "foo", "job", "bar"))
_, err = rr.exec(context.TODO(), time.Now(), 0)
_, err = rr.exec(context.TODO(), time.Now(), 0, nil)
if err != nil {
t.Fatalf("cannot execute recording rule: %s", err)
}
@@ -479,7 +479,7 @@ func TestRecordingRuleExec_Partial(t *testing.T) {
}
rule.Debug = true
rule.q = fq
got, err := rule.exec(context.TODO(), ts, 0)
got, err := rule.exec(context.TODO(), ts, 0, nil)
want := []prompb.TimeSeries{
newTimeSeries([]float64{10}, []int64{ts.UnixNano()}, []prompb.Label{
{

View File

@@ -4,12 +4,15 @@ import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/VictoriaMetrics/metrics"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/datasource"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/remotewrite"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prompb"
)
@@ -25,7 +28,7 @@ type Rule interface {
ToAPI() ApiRule
// exec executes the rule with given context at the given timestamp and limit.
// returns an err if number of resulting time series exceeds the limit.
exec(ctx context.Context, ts time.Time, limit int) ([]prompb.TimeSeries, error)
exec(ctx context.Context, ts time.Time, limit int, getRemoteReadQuerier func(enableDebug bool) datasource.Querier) ([]prompb.TimeSeries, error)
// execRange executes the rule on the given time range.
execRange(ctx context.Context, start, end time.Time) ([]prompb.TimeSeries, error)
// updateWith performs modification of current Rule
@@ -118,7 +121,7 @@ func (s *ruleState) add(e StateEntry) {
s.entries[s.cur] = e
}
func replayRule(r Rule, start, end time.Time, rw remotewrite.RWClient, replayRuleRetryAttempts int) (int, error) {
func replayRule(r Rule, start, end time.Time, rw remotewrite.RWClient, replayRuleRetryAttempts int, continueWithExecutionErr bool) (int, error) {
var err error
var tss []prompb.TimeSeries
for i := range replayRuleRetryAttempts {
@@ -126,6 +129,22 @@ func replayRule(r Rule, start, end time.Time, rw remotewrite.RWClient, replayRul
if err == nil {
break
}
// retry request if possible to tolerate temporary network or datasource unavailability issues
var esc *httpserver.ErrorWithStatusCode
if errors.As(err, &esc) {
statusCode := esc.StatusCode
// if the status code is 400 or 422, the query failed due to reasons such as an expression syntax error or a resource limit being hit,
// rather than datasource unavailability.
// Continue replaying but skip the problematic execution if continueWithExecutionErr is true, otherwise, return the error without retry.
if statusCode == http.StatusUnprocessableEntity || statusCode == http.StatusBadRequest {
if continueWithExecutionErr {
logger.Errorf("rule %q: %s", r, err)
return 0, nil
} else {
return 0, err
}
}
}
logger.Errorf("attempt %d to execute rule %q failed: %s", i+1, r, err)
time.Sleep(time.Second)
}

View File

@@ -96,6 +96,7 @@ func main() {
flag.CommandLine.SetOutput(os.Stdout)
flag.Usage = usage
envflag.Parse()
initSecretFlags()
buildinfo.Init()
logger.Init()
@@ -911,3 +912,8 @@ func slowdownUnauthorizedResponse(r *http.Request) {
}
timerpool.Put(t)
}
// initSecretFlags manages the secret flags for this app and must be called after flag parsing and before logger init.
func initSecretFlags() {
pushmetrics.InitSecretFlags()
}

View File

@@ -47,9 +47,8 @@ func main() {
// Write flags and help message to stdout, since it is easier to grep or pipe.
flag.CommandLine.SetOutput(os.Stdout)
flag.Usage = usage
flagutil.RegisterSecretFlag("snapshot.createURL")
flagutil.RegisterSecretFlag("snapshot.deleteURL")
envflag.Parse()
initSecretFlags()
buildinfo.Init()
logger.Init()
@@ -273,3 +272,10 @@ func newRemoteOriginFS(ctx context.Context) (common.RemoteFS, error) {
}
return fs, nil
}
// initSecretFlags manages the secret flags for this app and must be called after flag parsing and before logger init.
func initSecretFlags() {
flagutil.RegisterSecretFlag("snapshot.createURL")
flagutil.RegisterSecretFlag("snapshot.deleteURL")
pushmetrics.InitSecretFlags()
}

View File

@@ -47,6 +47,7 @@ func main() {
start := time.Now()
beforeFn := func(c *cli.Context) error {
flag.Parse()
initSecretFlags()
logger.Init()
isSilent = c.Bool(globalSilent)
if c.Bool(globalDisableProgressBar) {
@@ -619,3 +620,8 @@ func initConfigVM(c *cli.Context) (vm.Config, error) {
Backoff: bf,
}, nil
}
// initSecretFlags manages the secret flags for this app and must be called after flag parsing and before logger init.
func initSecretFlags() {
pushmetrics.InitSecretFlags()
}

View File

@@ -8,12 +8,14 @@ import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gogo/protobuf/proto"
"github.com/golang/snappy"
"github.com/prometheus/prometheus/config"
"github.com/prometheus/prometheus/model/histogram"
"github.com/prometheus/prometheus/prompb"
"github.com/prometheus/prometheus/storage/remote"
"github.com/prometheus/prometheus/tsdb/chunkenc"
@@ -234,9 +236,29 @@ func processResponse(body io.ReadCloser, callback StreamCallback) error {
// shouldn't be accounted as an error.
for _, res := range readResp.Results {
for _, ts := range res.Timeseries {
vmTs := convertSamples(ts.Samples, ts.Labels)
if err := callback(vmTs); err != nil {
return err
// A series contains either float samples or native histogram samples.
// Both fields are processed independently, since a series may switch
// from float to native histogram representation at some point in time,
// so the requested time range may contain samples of both types.
if len(ts.Samples) > 0 {
vmTs := convertSamples(ts.Samples, ts.Labels)
if err := callback(vmTs); err != nil {
return err
}
}
if len(ts.Histograms) > 0 {
hSamples := make([]histogramSample, 0, len(ts.Histograms))
for _, h := range ts.Histograms {
hSamples = append(hSamples, histogramSample{
timestamp: h.Timestamp,
fh: h.ToFloatHistogram(),
})
}
for _, vmTs := range convertHistograms(hSamples, ts.Labels) {
if err := callback(vmTs); err != nil {
return err
}
}
}
}
}
@@ -263,17 +285,45 @@ func processStreamResponse(body io.ReadCloser, callback StreamCallback) error {
for _, series := range res.ChunkedSeries {
samples := make([]prompb.Sample, 0)
var hSamples []histogramSample
for _, chunk := range series.Chunks {
s, err := parseSamples(chunk.Data)
if err != nil {
return err
switch chunk.Type {
case prompb.Chunk_XOR, prompb.Chunk_UNKNOWN:
// In proto3 the `type` field may be left unset (UNKNOWN) for XOR chunks.
// Prometheus remote.proto: "REQUIREMENT: when using proto3, this field
// MUST be set when using anything else than XOR". Senders before native
// histograms support (Prometheus < 2.40) do not set this field at all,
// so UNKNOWN chunks must be parsed as XOR ones.
s, err := parseSamples(chunk.Data)
if err != nil {
return err
}
samples = append(samples, s...)
case prompb.Chunk_HISTOGRAM, prompb.Chunk_FLOAT_HISTOGRAM:
hs, err := parseHistograms(chunk.Type, chunk.Data)
if err != nil {
return err
}
hSamples = append(hSamples, hs...)
default:
return fmt.Errorf("unsupported chunk encoding %q", chunk.Type)
}
samples = append(samples, s...)
}
ts := convertSamples(samples, series.Labels)
if err := callback(ts); err != nil {
return err
// A series contains either XOR chunks or native histogram chunks.
// Both are processed independently, since a series may switch
// from float to native histogram representation at some point in time,
// so the requested time range may contain chunks of both types.
if len(samples) > 0 {
ts := convertSamples(samples, series.Labels)
if err := callback(ts); err != nil {
return err
}
}
for _, ts := range convertHistograms(hSamples, series.Labels) {
if err := callback(ts); err != nil {
return err
}
}
}
}
@@ -312,6 +362,151 @@ func parseSamples(chunk []byte) ([]prompb.Sample, error) {
return samples, it.Err()
}
// histogramSample represents a single native histogram sample.
type histogramSample struct {
timestamp int64
fh *histogram.FloatHistogram
}
func parseHistograms(encoding prompb.Chunk_Encoding, chunk []byte) ([]histogramSample, error) {
var enc chunkenc.Encoding
switch encoding {
case prompb.Chunk_HISTOGRAM:
enc = chunkenc.EncHistogram
case prompb.Chunk_FLOAT_HISTOGRAM:
enc = chunkenc.EncFloatHistogram
default:
return nil, fmt.Errorf("unsupported histogram chunk encoding %q", encoding)
}
c, err := chunkenc.FromData(enc, chunk)
if err != nil {
return nil, fmt.Errorf("error read chunk: %w", err)
}
var hSamples []histogramSample
it := c.Iterator(nil)
for {
typ := it.Next()
if typ == chunkenc.ValNone {
break
}
switch typ {
case chunkenc.ValHistogram:
ts, h := it.AtHistogram(nil)
hSamples = append(hSamples, histogramSample{
timestamp: ts,
fh: h.ToFloat(nil),
})
case chunkenc.ValFloatHistogram:
ts, fh := it.AtFloatHistogram(nil)
hSamples = append(hSamples, histogramSample{
timestamp: ts,
fh: fh,
})
default:
// Skip unsupported values
continue
}
}
if err := it.Err(); err != nil {
return nil, fmt.Errorf("error iterate over chunks: %w", err)
}
return hSamples, nil
}
// convertHistograms converts native histogram samples into VictoriaMetrics histogram
// time series in the same way as VictoriaMetrics converts native histograms
// received via Prometheus remote write protocol: every native histogram sample
// is converted into `<name>_count` and `<name>_sum` series plus a set of
// `<name>_bucket` series with `vmrange` labels containing non-cumulative bucket counts.
// The only difference is that for native histograms with custom buckets (NHCB)
// bucket bounds are taken from the custom values, while the remote write protocol
// parser ignores custom values and estimates the bounds with the exponential formula.
// See https://prometheus.io/docs/specs/native_histograms/#data-model
func convertHistograms(hSamples []histogramSample, labels []prompb.Label) []*vm.TimeSeries {
if len(hSamples) == 0 {
return nil
}
labelPairs := make([]vm.LabelPair, 0, len(labels))
nameValue := ""
for _, label := range labels {
if label.Name == "__name__" {
nameValue = label.Value
continue
}
labelPairs = append(labelPairs, vm.LabelPair{Name: label.Name, Value: label.Value})
}
// the metric has no name, skip it in the same way as VictoriaMetrics does
// when it receives a native histogram without the metric name via remote write protocol.
if nameValue == "" {
return nil
}
countSeries := &vm.TimeSeries{
Name: nameValue + "_count",
LabelPairs: labelPairs,
}
sumSeries := &vm.TimeSeries{
Name: nameValue + "_sum",
LabelPairs: labelPairs,
}
bucketSeries := make(map[string]*vm.TimeSeries)
// vmranges preserves the order of bucketSeries creation
// in order to get deterministic results.
var vmranges []string
for _, hs := range hSamples {
fh := hs.fh
countSeries.Timestamps = append(countSeries.Timestamps, hs.timestamp)
countSeries.Values = append(countSeries.Values, fh.Count)
sumSeries.Timestamps = append(sumSeries.Timestamps, hs.timestamp)
sumSeries.Values = append(sumSeries.Values, fh.Sum)
it := fh.AllBucketIterator()
for it.Next() {
b := it.At()
if b.Count <= 0 {
continue
}
vmrange := formatVmrange(b.Lower, b.Upper)
s := bucketSeries[vmrange]
if s == nil {
bucketLabelPairs := make([]vm.LabelPair, len(labelPairs), len(labelPairs)+1)
copy(bucketLabelPairs, labelPairs)
bucketLabelPairs = append(bucketLabelPairs, vm.LabelPair{Name: "vmrange", Value: vmrange})
s = &vm.TimeSeries{
Name: nameValue + "_bucket",
LabelPairs: bucketLabelPairs,
}
bucketSeries[vmrange] = s
vmranges = append(vmranges, vmrange)
}
s.Timestamps = append(s.Timestamps, hs.timestamp)
s.Values = append(s.Values, b.Count)
}
}
tss := make([]*vm.TimeSeries, 0, 2+len(vmranges))
tss = append(tss, countSeries, sumSeries)
for _, vmrange := range vmranges {
tss = append(tss, bucketSeries[vmrange])
}
return tss
}
// formatVmrange formats the given bucket bounds into `vmrange` label value
// in the same way as VictoriaMetrics does for native histograms
// received via Prometheus remote write protocol.
func formatVmrange(lower, upper float64) string {
b := make([]byte, 0, 24)
b = strconv.AppendFloat(b, lower, 'e', 3, 64)
b = append(b, "..."...)
b = strconv.AppendFloat(b, upper, 'e', 3, 64)
return string(b)
}
type keyValue struct {
key string
value string

View File

@@ -0,0 +1,334 @@
package remoteread
import (
"bytes"
"io"
"reflect"
"testing"
"github.com/gogo/protobuf/proto"
"github.com/golang/snappy"
"github.com/prometheus/prometheus/model/histogram"
"github.com/prometheus/prometheus/prompb"
"github.com/prometheus/prometheus/storage/remote"
"github.com/prometheus/prometheus/tsdb/chunkenc"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/vm"
)
func testHistogram(mul int64) *histogram.Histogram {
return &histogram.Histogram{
Schema: 0,
Count: uint64(10 * mul),
Sum: 25.5 * float64(mul),
ZeroThreshold: 0.001,
ZeroCount: uint64(2 * mul),
PositiveSpans: []histogram.Span{{Offset: 0, Length: 2}},
PositiveBuckets: []int64{1 * mul, 2 * mul},
NegativeSpans: []histogram.Span{{Offset: 0, Length: 1}},
NegativeBuckets: []int64{4 * mul},
}
}
func TestConvertHistograms(t *testing.T) {
f := func(hSamples []histogramSample, labels []prompb.Label, expected []*vm.TimeSeries) {
t.Helper()
tss := convertHistograms(hSamples, labels)
if !reflect.DeepEqual(tss, expected) {
t.Fatalf("unexpected result\ngot:\n%v\nwant:\n%v", tss, expected)
}
}
// series without samples
f(nil, []prompb.Label{{Name: "__name__", Value: "foo"}}, nil)
// series without the metric name must be skipped
f([]histogramSample{
{timestamp: 1000, fh: testHistogram(1).ToFloat(nil)},
}, []prompb.Label{{Name: "job", Value: "bar"}}, nil)
// native histogram must be converted to _count, _sum and _bucket series
// in the same way as VictoriaMetrics does for Prometheus remote write protocol
labels := []prompb.Label{
{Name: "__name__", Value: "request_duration_seconds"},
{Name: "job", Value: "bar"},
}
jobLabel := []vm.LabelPair{{Name: "job", Value: "bar"}}
bucketLabels := func(vmrange string) []vm.LabelPair {
return []vm.LabelPair{
{Name: "job", Value: "bar"},
{Name: "vmrange", Value: vmrange},
}
}
f([]histogramSample{
{timestamp: 1000, fh: testHistogram(1).ToFloat(nil)},
{timestamp: 2000, fh: testHistogram(2).ToFloat(nil)},
}, labels, []*vm.TimeSeries{
{
Name: "request_duration_seconds_count",
LabelPairs: jobLabel,
Timestamps: []int64{1000, 2000},
Values: []float64{10, 20},
},
{
Name: "request_duration_seconds_sum",
LabelPairs: jobLabel,
Timestamps: []int64{1000, 2000},
Values: []float64{25.5, 51},
},
{
Name: "request_duration_seconds_bucket",
LabelPairs: bucketLabels("-1.000e+00...-5.000e-01"),
Timestamps: []int64{1000, 2000},
Values: []float64{4, 8},
},
{
Name: "request_duration_seconds_bucket",
LabelPairs: bucketLabels("-1.000e-03...1.000e-03"),
Timestamps: []int64{1000, 2000},
Values: []float64{2, 4},
},
{
Name: "request_duration_seconds_bucket",
LabelPairs: bucketLabels("5.000e-01...1.000e+00"),
Timestamps: []int64{1000, 2000},
Values: []float64{1, 2},
},
{
Name: "request_duration_seconds_bucket",
LabelPairs: bucketLabels("1.000e+00...2.000e+00"),
Timestamps: []int64{1000, 2000},
Values: []float64{3, 6},
},
})
}
func TestParseHistograms(t *testing.T) {
c := chunkenc.NewHistogramChunk()
app, err := c.Appender()
if err != nil {
t.Fatalf("cannot create chunk appender: %s", err)
}
if _, _, _, err := app.AppendHistogram(nil, 0, 1000, testHistogram(1), true); err != nil {
t.Fatalf("cannot append histogram: %s", err)
}
if _, _, _, err := app.AppendHistogram(nil, 0, 2000, testHistogram(2), true); err != nil {
t.Fatalf("cannot append histogram: %s", err)
}
hSamples, err := parseHistograms(prompb.Chunk_HISTOGRAM, c.Bytes())
if err != nil {
t.Fatalf("cannot parse histogram chunk: %s", err)
}
if len(hSamples) != 2 {
t.Fatalf("unexpected number of histogram samples; got %d; want 2", len(hSamples))
}
for i, expected := range []struct {
timestamp int64
count float64
sum float64
}{
{timestamp: 1000, count: 10, sum: 25.5},
{timestamp: 2000, count: 20, sum: 51},
} {
if hSamples[i].timestamp != expected.timestamp {
t.Fatalf("unexpected timestamp; got %d; want %d", hSamples[i].timestamp, expected.timestamp)
}
if hSamples[i].fh.Count != expected.count {
t.Fatalf("unexpected count; got %f; want %f", hSamples[i].fh.Count, expected.count)
}
if hSamples[i].fh.Sum != expected.sum {
t.Fatalf("unexpected sum; got %f; want %f", hSamples[i].fh.Sum, expected.sum)
}
}
// unsupported chunk encoding must return error
if _, err := parseHistograms(prompb.Chunk_XOR, c.Bytes()); err == nil {
t.Fatalf("expecting non-nil error for unsupported chunk encoding")
}
}
func TestProcessResponse(t *testing.T) {
readResp := &prompb.ReadResponse{
Results: []*prompb.QueryResult{
{
Timeseries: []*prompb.TimeSeries{
{
Labels: []prompb.Label{
{Name: "__name__", Value: "cpu_usage"},
{Name: "job", Value: "bar"},
},
Samples: []prompb.Sample{
{Timestamp: 1000, Value: 1.5},
},
},
{
Labels: []prompb.Label{
{Name: "__name__", Value: "request_duration_seconds"},
{Name: "job", Value: "bar"},
},
Histograms: []prompb.Histogram{
prompb.FromIntHistogram(1000, testHistogram(1)),
},
},
},
},
},
}
data, err := proto.Marshal(readResp)
if err != nil {
t.Fatalf("cannot marshal ReadResponse: %s", err)
}
compressed := snappy.Encode(nil, data)
var tss []*vm.TimeSeries
err = processResponse(io.NopCloser(bytes.NewReader(compressed)), func(ts *vm.TimeSeries) error {
tss = append(tss, ts)
return nil
})
if err != nil {
t.Fatalf("cannot process response: %s", err)
}
// 1 float series + _count + _sum + 4 buckets
if len(tss) != 7 {
t.Fatalf("unexpected number of time series; got %d; want 7", len(tss))
}
if tss[0].Name != "cpu_usage" || !reflect.DeepEqual(tss[0].Values, []float64{1.5}) {
t.Fatalf("unexpected float series: %v", tss[0])
}
if tss[1].Name != "request_duration_seconds_count" || !reflect.DeepEqual(tss[1].Values, []float64{10}) {
t.Fatalf("unexpected _count series: %v", tss[1])
}
if tss[2].Name != "request_duration_seconds_sum" || !reflect.DeepEqual(tss[2].Values, []float64{25.5}) {
t.Fatalf("unexpected _sum series: %v", tss[2])
}
for _, ts := range tss[3:] {
if ts.Name != "request_duration_seconds_bucket" {
t.Fatalf("unexpected bucket series name %q", ts.Name)
}
}
}
type nopFlusher struct{}
func (nopFlusher) Flush() {}
func TestProcessStreamResponse(t *testing.T) {
// build a histogram chunk
hc := chunkenc.NewHistogramChunk()
hApp, err := hc.Appender()
if err != nil {
t.Fatalf("cannot create histogram chunk appender: %s", err)
}
if _, _, _, err := hApp.AppendHistogram(nil, 0, 1000, testHistogram(1), true); err != nil {
t.Fatalf("cannot append histogram: %s", err)
}
// build a float chunk
xc := chunkenc.NewXORChunk()
xApp, err := xc.Appender()
if err != nil {
t.Fatalf("cannot create xor chunk appender: %s", err)
}
xApp.Append(0, 1000, 1.5)
res := &prompb.ChunkedReadResponse{
ChunkedSeries: []*prompb.ChunkedSeries{
{
Labels: []prompb.Label{
{Name: "__name__", Value: "request_duration_seconds"},
{Name: "job", Value: "bar"},
},
Chunks: []prompb.Chunk{
{Type: prompb.Chunk_HISTOGRAM, Data: hc.Bytes()},
},
},
{
Labels: []prompb.Label{
{Name: "__name__", Value: "cpu_usage"},
},
Chunks: []prompb.Chunk{
{Type: prompb.Chunk_XOR, Data: xc.Bytes()},
},
},
{
Labels: []prompb.Label{
{Name: "__name__", Value: "memory_usage"},
},
Chunks: []prompb.Chunk{
// the `type` field may be unset for XOR chunks,
// such chunks must be parsed as XOR ones
{Type: prompb.Chunk_UNKNOWN, Data: xc.Bytes()},
},
},
},
}
data, err := proto.Marshal(res)
if err != nil {
t.Fatalf("cannot marshal ChunkedReadResponse: %s", err)
}
var buf bytes.Buffer
cw := remote.NewChunkedWriter(&buf, nopFlusher{})
if _, err := cw.Write(data); err != nil {
t.Fatalf("cannot write chunked response: %s", err)
}
var tss []*vm.TimeSeries
err = processStreamResponse(io.NopCloser(&buf), func(ts *vm.TimeSeries) error {
tss = append(tss, ts)
return nil
})
if err != nil {
t.Fatalf("cannot process stream response: %s", err)
}
// _count + _sum + 4 buckets + 1 float series + 1 float series from UNKNOWN chunk
if len(tss) != 8 {
t.Fatalf("unexpected number of time series; got %d; want 8", len(tss))
}
if tss[0].Name != "request_duration_seconds_count" || !reflect.DeepEqual(tss[0].Values, []float64{10}) {
t.Fatalf("unexpected _count series: %v", tss[0])
}
if tss[1].Name != "request_duration_seconds_sum" || !reflect.DeepEqual(tss[1].Values, []float64{25.5}) {
t.Fatalf("unexpected _sum series: %v", tss[1])
}
for _, ts := range tss[2:6] {
if ts.Name != "request_duration_seconds_bucket" {
t.Fatalf("unexpected bucket series name %q", ts.Name)
}
}
if tss[6].Name != "cpu_usage" || !reflect.DeepEqual(tss[6].Values, []float64{1.5}) {
t.Fatalf("unexpected float series: %v", tss[6])
}
if tss[7].Name != "memory_usage" || !reflect.DeepEqual(tss[7].Values, []float64{1.5}) {
t.Fatalf("unexpected float series from UNKNOWN chunk: %v", tss[7])
}
}
func TestParseFloatHistograms(t *testing.T) {
c := chunkenc.NewFloatHistogramChunk()
app, err := c.Appender()
if err != nil {
t.Fatalf("cannot create chunk appender: %s", err)
}
fh := testHistogram(1).ToFloat(nil)
if _, _, _, err := app.AppendFloatHistogram(nil, 0, 1000, fh, true); err != nil {
t.Fatalf("cannot append float histogram: %s", err)
}
hSamples, err := parseHistograms(prompb.Chunk_FLOAT_HISTOGRAM, c.Bytes())
if err != nil {
t.Fatalf("cannot parse float histogram chunk: %s", err)
}
if len(hSamples) != 1 {
t.Fatalf("unexpected number of histogram samples; got %d; want 1", len(hSamples))
}
if hSamples[0].timestamp != 1000 {
t.Fatalf("unexpected timestamp; got %d; want 1000", hSamples[0].timestamp)
}
if hSamples[0].fh.Count != 10 {
t.Fatalf("unexpected count; got %f; want 10", hSamples[0].fh.Count)
}
}

View File

@@ -38,6 +38,7 @@ func main() {
flag.CommandLine.SetOutput(os.Stdout)
flag.Usage = usage
envflag.Parse()
initSecretFlags()
buildinfo.Init()
logger.Init()
@@ -112,3 +113,8 @@ func newSrcFS(ctx context.Context) (common.RemoteFS, error) {
}
return fs, nil
}
// initSecretFlags manages the secret flags for this app and must be called after flag parsing and before logger init.
func initSecretFlags() {
pushmetrics.InitSecretFlags()
}

View File

@@ -59,6 +59,12 @@ func Init(vmselectMaxConcurrentRequests int, vmselectMaxQueueDuration time.Durat
initVMUIConfig()
vmalertproxy.Init(*vmalertProxyURL)
}
// InitSecretFlags manages the secret flags for this pkg and must be called by app-level initSecretFlags.
// It should run before logger initialization and package Init() (if exists).
func InitSecretFlags() {
flagutil.RegisterSecretFlag("vmalert.proxyURL")
}

View File

@@ -516,7 +516,7 @@ func DeleteHandler(startTime time.Time, r *http.Request) error {
cp.deadline = searchutil.GetDeadlineForDelete(r, startTime)
if !cp.IsDefaultTimeRange() {
return fmt.Errorf("start=%d and end=%d args aren't supported. Remove these args from the query in order to delete all the matching metrics", cp.start, cp.end)
return fmt.Errorf("delete API does not support specific time ranges using start and end args, the series can only be deleted completely")
}
sq := storage.NewSearchQuery(cp.start, cp.end, cp.filterss, *maxDeleteSeries)
deletedCount, err := netstorage.DeleteSeries(nil, sq, cp.deadline)
@@ -540,11 +540,11 @@ func LabelValuesHandler(qt *querytracer.Tracer, startTime time.Time, labelName s
cp, err := getCommonParamsForLabelsAPI(r, startTime, false)
if err != nil {
return err
return httpserver.InvalidParamError(err)
}
limit, err := httputil.GetInt(r, "limit")
if err != nil {
return err
return httpserver.InvalidParamError(err)
}
sq := storage.NewSearchQuery(cp.start, cp.end, cp.filterss, *maxLabelsAPISeries)
@@ -584,7 +584,7 @@ func TSDBStatusHandler(qt *querytracer.Tracer, startTime time.Time, w http.Respo
cp, err := getCommonParams(r, startTime, false)
if err != nil {
return err
return httpserver.InvalidParamError(err)
}
cp.deadline = searchutil.GetDeadlineForStatusRequest(r, startTime)
@@ -596,7 +596,7 @@ func TSDBStatusHandler(qt *querytracer.Tracer, startTime time.Time, w http.Respo
} else {
t, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return fmt.Errorf("cannot parse `date` arg %q: %w", dateStr, err)
return httpserver.InvalidParamError(fmt.Errorf("cannot parse `date` arg %q: %w", dateStr, err))
}
date = uint64(t.Unix()) / secsPerDay
}
@@ -607,7 +607,7 @@ func TSDBStatusHandler(qt *querytracer.Tracer, startTime time.Time, w http.Respo
if len(topNStr) > 0 {
n, err := strconv.Atoi(topNStr)
if err != nil {
return fmt.Errorf("cannot parse `topN` arg %q: %w", topNStr, err)
return httpserver.InvalidParamError(fmt.Errorf("cannot parse `topN` arg %q: %w", topNStr, err))
}
if n <= 0 {
n = 1
@@ -645,11 +645,11 @@ func LabelsHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseW
cp, err := getCommonParamsForLabelsAPI(r, startTime, false)
if err != nil {
return err
return httpserver.InvalidParamError(err)
}
limit, err := httputil.GetInt(r, "limit")
if err != nil {
return err
return httpserver.InvalidParamError(err)
}
sq := storage.NewSearchQuery(cp.start, cp.end, cp.filterss, *maxLabelsAPISeries)
labels, err := netstorage.LabelNames(qt, sq, limit, cp.deadline)
@@ -671,10 +671,9 @@ func LabelsHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseW
//
// See https://prometheus.io/docs/prometheus/latest/querying/api/#querying-metric-metadata
func MetadataHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWriter, r *http.Request) error {
limit, err := httputil.GetInt(r, "limit")
if err != nil {
return err
return httpserver.InvalidParamError(err)
}
if limit < 0 {
limit = 0
@@ -734,11 +733,11 @@ func SeriesHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseW
// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/91
cp, err := getCommonParamsForLabelsAPI(r, startTime, true)
if err != nil {
return err
return httpserver.InvalidParamError(err)
}
limit, err := httputil.GetInt(r, "limit")
if err != nil {
return err
return httpserver.InvalidParamError(err)
}
sq := storage.NewSearchQuery(cp.start, cp.end, cp.filterss, *maxSeriesLimit)
@@ -772,19 +771,19 @@ func QueryHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWr
mayCache := !httputil.GetBool(r, "nocache")
query := r.FormValue("query")
if len(query) == 0 {
return fmt.Errorf("missing `query` arg")
return httpserver.InvalidParamError(fmt.Errorf("missing `query` arg"))
}
start, err := httputil.GetTime(r, "time", ct)
if err != nil {
return err
return httpserver.InvalidParamError(err)
}
lookbackDelta, err := getMaxLookback(r)
if err != nil {
return err
return httpserver.InvalidParamError(err)
}
step, err := httputil.GetDuration(r, "step", lookbackDelta)
if err != nil {
return err
return httpserver.InvalidParamError(err)
}
if step <= 0 {
step = defaultStep
@@ -792,16 +791,16 @@ func QueryHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWr
maxLen := searchutil.GetMaxQueryLen()
if len(query) > maxLen {
return fmt.Errorf("too long query; got %d bytes; mustn't exceed `-search.maxQueryLen=%d` bytes", len(query), maxLen)
return httpserver.InvalidParamError(fmt.Errorf("too long query; got %d bytes; mustn't exceed `-search.maxQueryLen=%d` bytes", len(query), maxLen))
}
etfs, err := searchutil.GetExtraTagFilters(r)
if err != nil {
return err
return httpserver.InvalidParamError(err)
}
if childQuery, windowExpr, offsetExpr := promql.IsMetricSelectorWithRollup(query); childQuery != "" {
window, err := windowExpr.NonNegativeDuration(step)
if err != nil {
return fmt.Errorf("cannot parse lookbehind window in square brackets at %s: %w", query, err)
return httpserver.InvalidParamError(fmt.Errorf("cannot parse lookbehind window in square brackets at %s: %w", query, err))
}
offset := offsetExpr.Duration(step)
start -= offset
@@ -815,7 +814,7 @@ func QueryHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWr
tagFilterss, err := getTagFilterssFromMatches([]string{childQuery})
if err != nil {
return err
return httpserver.InvalidParamError(err)
}
filterss := searchutil.JoinTagFilterss(tagFilterss, etfs)
@@ -831,22 +830,25 @@ func QueryHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWr
return nil
}
if childQuery, windowExpr, stepExpr, offsetExpr := promql.IsRollup(query); childQuery != "" {
if len(childQuery) > maxLen {
return httpserver.InvalidParamError(fmt.Errorf("too long query; got %d bytes; mustn't exceed `-search.maxQueryLen=%d` bytes", len(childQuery), maxLen))
}
newStep, err := stepExpr.NonNegativeDuration(step)
if err != nil {
return fmt.Errorf("cannot parse step in square brackets at %s: %w", query, err)
return httpserver.InvalidParamError(fmt.Errorf("cannot parse step in square brackets at %s: %w", query, err))
}
if newStep > 0 {
step = newStep
}
window, err := windowExpr.NonNegativeDuration(step)
if err != nil {
return fmt.Errorf("cannot parse lookbehind window in square brackets at %s: %w", query, err)
return httpserver.InvalidParamError(fmt.Errorf("cannot parse lookbehind window in square brackets at %s: %w", query, err))
}
offset := offsetExpr.Duration(step)
start -= offset
end := start
start = end - window
if err := queryRangeHandler(qt, startTime, w, childQuery, start, end, step, r, ct, etfs); err != nil {
if err := queryRangeHandler(qt, startTime, w, childQuery, start, end, step, lookbackDelta, r, ct, etfs); err != nil {
return fmt.Errorf("error when executing query=%q on the time range (start=%d, end=%d, step=%d): %w", childQuery, start, end, step, err)
}
return nil
@@ -854,7 +856,7 @@ func QueryHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWr
queryOffset, err := getLatencyOffsetMilliseconds(r)
if err != nil {
return err
return httpserver.InvalidParamError(err)
}
if !httputil.GetBool(r, "nocache") && ct-start < queryOffset && start-ct < queryOffset {
// Adjust start time only if `nocache` arg isn't set.
@@ -928,45 +930,43 @@ func QueryRangeHandler(qt *querytracer.Tracer, startTime time.Time, w http.Respo
ct := startTime.UnixNano() / 1e6
query := r.FormValue("query")
if len(query) == 0 {
return fmt.Errorf("missing `query` arg")
return httpserver.InvalidParamError(fmt.Errorf("missing `query` arg"))
}
maxLen := searchutil.GetMaxQueryLen()
if len(query) > maxLen {
return httpserver.InvalidParamError(fmt.Errorf("too long query; got %d bytes; mustn't exceed `-search.maxQueryLen=%d` bytes", len(query), maxLen))
}
start, err := httputil.GetTime(r, "start", ct-defaultStep)
if err != nil {
return err
return httpserver.InvalidParamError(err)
}
end, err := httputil.GetTime(r, "end", ct)
if err != nil {
return err
return httpserver.InvalidParamError(err)
}
step, err := httputil.GetDuration(r, "step", defaultStep)
if err != nil {
return err
return httpserver.InvalidParamError(err)
}
etfs, err := searchutil.GetExtraTagFilters(r)
if err != nil {
return err
return httpserver.InvalidParamError(err)
}
if err := queryRangeHandler(qt, startTime, w, query, start, end, step, r, ct, etfs); err != nil {
lookbackDelta, err := getMaxLookback(r)
if err != nil {
return httpserver.InvalidParamError(err)
}
if err := queryRangeHandler(qt, startTime, w, query, start, end, step, lookbackDelta, r, ct, etfs); err != nil {
return fmt.Errorf("error when executing query=%q on the time range (start=%d, end=%d, step=%d): %w", query, start, end, step, err)
}
return nil
}
func queryRangeHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWriter, query string,
start, end, step int64, r *http.Request, ct int64, etfs [][]storage.TagFilter) error {
start, end, step, lookbackDelta int64, r *http.Request, ct int64, etfs [][]storage.TagFilter) error {
deadline := searchutil.GetDeadlineForQuery(r, startTime)
mayCache := !httputil.GetBool(r, "nocache")
optimizeRepeatedBinaryOpSubexprs := httputil.GetBool(r, "optimize_repeated_binary_op_subexprs")
lookbackDelta, err := getMaxLookback(r)
if err != nil {
return err
}
// Validate input args.
maxLen := searchutil.GetMaxQueryLen()
if len(query) > maxLen {
return fmt.Errorf("too long query; got %d bytes; mustn't exceed `-search.maxQueryLen=%d` bytes", len(query), maxLen)
}
if start > end {
end = start + defaultStep
}
@@ -1005,7 +1005,7 @@ func queryRangeHandler(qt *querytracer.Tracer, startTime time.Time, w http.Respo
if step < maxStepForPointsAdjustment.Milliseconds() {
queryOffset, err := getLatencyOffsetMilliseconds(r)
if err != nil {
return err
return httpserver.InvalidParamError(err)
}
if ct-queryOffset < end {
result = adjustLastPoints(result, ct-queryOffset, ct+step)
@@ -1156,13 +1156,13 @@ func QueryStatsHandler(w http.ResponseWriter, r *http.Request) error {
if len(topNStr) > 0 {
n, err := strconv.Atoi(topNStr)
if err != nil {
return fmt.Errorf("cannot parse `topN` arg %q: %w", topNStr, err)
return httpserver.InvalidParamError(fmt.Errorf("cannot parse `topN` arg %q: %w", topNStr, err))
}
topN = n
}
maxLifetimeMsecs, err := httputil.GetDuration(r, "maxLifetime", 10*60*1000)
if err != nil {
return fmt.Errorf("cannot parse `maxLifetime` arg: %w", err)
return httpserver.InvalidParamError(fmt.Errorf("cannot parse `maxLifetime` arg: %w", err))
}
maxLifetime := time.Duration(maxLifetimeMsecs) * time.Millisecond
w.Header().Set("Content-Type", "application/json")

View File

@@ -11,6 +11,7 @@ import (
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmselect/netstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmselect/querystats"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/decimal"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/querytracer"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage"
@@ -46,15 +47,15 @@ func Exec(qt *querytracer.Tracer, ec *EvalConfig, q string, isFirstPointOnly boo
e, err := parsePromQLWithCache(q)
if err != nil {
return nil, err
return nil, httpserver.InvalidParamError(err)
}
if *disableImplicitConversion || *logImplicitConversion {
isInvalid := metricsql.IsLikelyInvalid(e)
if isInvalid && *disableImplicitConversion {
// we don't add query=%q to err message as it will be added by the caller
return nil, fmt.Errorf("query requires implicit conversion and is rejected according to -search.disableImplicitConversion command-line flag. " +
"See https://docs.victoriametrics.com/victoriametrics/metricsql/#implicit-query-conversions for details")
return nil, httpserver.InvalidParamError(fmt.Errorf("query requires implicit conversion and is rejected according to -search.disableImplicitConversion command-line flag. " +
"See https://docs.victoriametrics.com/victoriametrics/metricsql/#implicit-query-conversions for details"))
}
if isInvalid && *logImplicitConversion {
logger.Warnf("query=%q requires implicit conversion, see https://docs.victoriametrics.com/victoriametrics/metricsql/#implicit-query-conversions for details", e.AppendString(nil))

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -37,7 +37,7 @@
<meta property="og:title" content="UI for VictoriaMetrics">
<meta property="og:url" content="https://victoriametrics.com/">
<meta property="og:description" content="Explore and troubleshoot your VictoriaMetrics data">
<script type="module" crossorigin src="./assets/index-D5egN2id.js"></script>
<script type="module" crossorigin src="./assets/index-B1dXK3k7.js"></script>
<link rel="modulepreload" crossorigin href="./assets/rolldown-runtime-CNC7AqOf.js">
<link rel="modulepreload" crossorigin href="./assets/vendor-DwJYpOdw.js">
<link rel="stylesheet" crossorigin href="./assets/vendor-CnsZ1jie.css">

View File

@@ -1,4 +1,4 @@
FROM golang:1.26.5 AS build-web-stage
FROM golang:1.26.6 AS build-web-stage
COPY build /build
WORKDIR /build

View File

@@ -0,0 +1,5 @@
<svg width="48" height="48" fill="#020202" xmlns="http://www.w3.org/2000/svg">
<path d="M24.5475 0C10.3246.0265251 1.11379 3.06365 4.40623 6.10077c0 0 12.32997 11.23333 16.58217 14.84083.8131.6896 2.1728 1.1936 3.5191 1.2201h.1199c1.3463-.0265 2.706-.5305 3.5191-1.2201 4.2522-3.5942 16.5422-14.84083 16.5422-14.84083C48.0478 3.06365 38.8636.0265251 24.6674 0"/>
<path d="M28.1579 27.0159c-.8131.6896-2.1728 1.1936-3.5191 1.2201h-.12c-1.3463-.0265-2.7059-.5305-3.519-1.2201-2.9725-2.5067-13.35639-11.87-17.26201-15.3979v5.4112c0 .5968.22661 1.3793.6265 1.7506C7.00358 21.1936 17.2675 30.5437 20.9731 33.6737c.8132.6896 2.1728 1.1936 3.5191 1.2201h.12c1.3463-.0265 2.7059-.5305 3.519-1.2201 3.679-3.13 13.9429-12.4536 16.6089-14.8939.4132-.3713.6265-1.1538.6265-1.7506V11.618c-3.9323 3.5411-14.3162 12.931-17.2354 15.3979h.0267Z"/>
<path d="M28.1579 39.748c-.8131.6897-2.1728 1.1937-3.5191 1.2202h-.12c-1.3463-.0265-2.7059-.5305-3.519-1.2202-2.9725-2.4933-13.35639-11.8567-17.26201-15.3978v5.4111c0 .5969.22661 1.3793.6265 1.7507C7.00358 33.9258 17.2675 43.2759 20.9731 46.4058c.8132.6897 2.1728 1.1937 3.5191 1.2202h.12c1.3463-.0265 2.7059-.5305 3.519-1.2202 3.679-3.1299 13.9429-12.4535 16.6089-14.8938.4132-.3714.6265-1.1538.6265-1.7507v-5.4111c-3.9323 3.5411-14.3162 12.931-17.2354 15.3978h.0267Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="utf-8"/>
<link rel="icon" href="/favicon.svg"/>
<link rel="apple-touch-icon" href="/favicon.svg"/>
<link rel="mask-icon" href="/favicon.svg" color="#000000">
<link id="favicon" rel="icon" href="/assets/favicon.svg" />
<link rel="apple-touch-icon" href="/assets/favicon.svg" />
<link id="mask-icon" rel="mask-icon" href="/assets/favicon.svg?no-inline" color="#000000">
<meta name="robots" content="noindex">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5"/>

View File

@@ -1 +0,0 @@
<svg width="48" height="48" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M24.5475 0C10.3246.0265251 1.11379 3.06365 4.40623 6.10077c0 0 12.32997 11.23333 16.58217 14.84083.8131.6896 2.1728 1.1936 3.5191 1.2201h.1199c1.3463-.0265 2.706-.5305 3.5191-1.2201 4.2522-3.5942 16.5422-14.84083 16.5422-14.84083C48.0478 3.06365 38.8636.0265251 24.6674 0" fill="#020202"/><path d="M28.1579 27.0159c-.8131.6896-2.1728 1.1936-3.5191 1.2201h-.12c-1.3463-.0265-2.7059-.5305-3.519-1.2201-2.9725-2.5067-13.35639-11.87-17.26201-15.3979v5.4112c0 .5968.22661 1.3793.6265 1.7506C7.00358 21.1936 17.2675 30.5437 20.9731 33.6737c.8132.6896 2.1728 1.1936 3.5191 1.2201h.12c1.3463-.0265 2.7059-.5305 3.519-1.2201 3.679-3.13 13.9429-12.4536 16.6089-14.8939.4132-.3713.6265-1.1538.6265-1.7506V11.618c-3.9323 3.5411-14.3162 12.931-17.2354 15.3979h.0267Z" fill="#020202"/><path d="M28.1579 39.748c-.8131.6897-2.1728 1.1937-3.5191 1.2202h-.12c-1.3463-.0265-2.7059-.5305-3.519-1.2202-2.9725-2.4933-13.35639-11.8567-17.26201-15.3978v5.4111c0 .5969.22661 1.3793.6265 1.7507C7.00358 33.9258 17.2675 43.2759 20.9731 46.4058c.8132.6897 2.1728 1.1937 3.5191 1.2202h.12c1.3463-.0265 2.7059-.5305 3.519-1.2202 3.679-3.1299 13.9429-12.4535 16.6089-14.8938.4132-.3714.6265-1.1538.6265-1.7507v-5.4111c-3.9323 3.5411-14.3162 12.931-17.2354 15.3978h.0267Z" fill="#020202"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -3,7 +3,7 @@
"name": "vmui",
"icons": [
{
"src": "favicon.svg",
"src": "./assets/favicon.svg",
"sizes": "any",
"type": "image/svg+xml"
}

View File

@@ -0,0 +1,61 @@
import { FC, useMemo } from "preact/compat";
import "./style.scss";
import { createFaviconUrl } from "../../../../utils/favicon";
import classNames from "classnames";
import { useBrowserTabSync } from "./hooks/useBrowserTabSync";
import { CloseIcon } from "../../../Main/Icons";
import { faviconColors } from "../../../../constants/faviconColors";
const BrowserTabController: FC = () => {
const { faviconColor, changeFaviconColor } = useBrowserTabSync();
const faviconUrl = useMemo(() => {
return createFaviconUrl(faviconColor);
}, [faviconColor]);
const createHandlerClick = (color?: string) => () => {
changeFaviconColor(color);
};
return (
<div className="vm-browser-tab-controller">
<p className="vm-server-configurator__title">Favicon color</p>
<div className="vm-browser-tab-controller-palette">
<div className="vm-browser-tab-controller-palette-list">
<button
className="vm-browser-tab-controller-palette-list__item vm-browser-tab-controller-palette-list__item_reset"
type="button"
onClick={createHandlerClick()}
aria-label="Reset favicon color"
>
<CloseIcon />
</button>
{faviconColors.map(color => (
<button
className={classNames({
"vm-browser-tab-controller-palette-list__item": true,
"vm-browser-tab-controller-palette-list__item_selected": faviconColor === color
})}
key={color}
type="button"
style={{ color }}
onClick={createHandlerClick(color)}
aria-label={`Set favicon color to ${color}`}
aria-pressed={faviconColor === color}
/>
))}
</div>
<img
className="vm-browser-tab-controller-palette__preview"
src={faviconUrl}
alt="Favicon preview"
/>
</div>
</div>
);
};
export default BrowserTabController;

View File

@@ -0,0 +1,41 @@
import useEventListener from "../../../../../hooks/useEventListener";
import { useEffect, useState } from "preact/compat";
import { getFromStorage, removeFromStorage, saveToStorage } from "../../../../../utils/storage";
import { getFaviconStorageKey, updateFaviconColor } from "../../../../../utils/favicon";
const storageKey = `FAVICON_COLOR:${getFaviconStorageKey()}` as const;
const getColorFromStorage = () => {
return getFromStorage(storageKey) as string | undefined;
};
export const useBrowserTabSync = () => {
const [faviconColor, setFaviconColor] = useState(getColorFromStorage);
const handleUpdateColor = () => {
setFaviconColor(getColorFromStorage());
};
const changeFaviconColor = (color?: string) => {
if (color) {
saveToStorage(storageKey, color);
} else {
removeFromStorage([storageKey]);
}
};
useEffect(() => {
handleUpdateColor();
}, []);
useEffect(() => {
updateFaviconColor(faviconColor);
}, [faviconColor]);
useEventListener("storage", handleUpdateColor);
return {
faviconColor,
changeFaviconColor,
};
};

View File

@@ -0,0 +1,71 @@
@use "src/styles/variables" as *;
$color-item-size: 28px;
$outline-width: 2px;
$outline-offset: 2px;
$outline-space: $outline-width + $outline-offset;
.vm-browser-tab-controller {
.vm-server-configurator__title {
padding-bottom: 2px;
}
&-palette {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: calc($padding-large * 2);
&-list {
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: $padding-small;
&__item {
width: $color-item-size;
height: $color-item-size;
aspect-ratio: 1;
border-radius: 50%;
background-color: currentColor;
cursor: pointer;
transition-property: transform;
transition-duration: 0.15s;
transition-timing-function: linear;
&:hover {
transform: scale(1.1);
}
&:focus-visible {
transform: scale(1.1);
}
&_reset {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: $border-divider;
color: $color-text-disabled;
padding: calc($padding-small / 2);
}
&_selected {
width: $color-item-size - 2 * $outline-space;
height: $color-item-size - 2 * $outline-space;
margin: $outline-space;
outline: $outline-width solid currentColor;
outline-offset: $outline-offset;
pointer-events: none;
}
}
}
&__preview {
width: $color-item-size;
height: auto;
}
}
}

View File

@@ -8,10 +8,12 @@ import Tooltip from "../../Main/Tooltip/Tooltip";
import LimitsConfigurator from "./LimitsConfigurator/LimitsConfigurator";
import { getAppModeEnable } from "../../../utils/app-mode";
import classNames from "classnames";
import Timezones from "./Timezones/Timezones";
import TimezonesPicker from "./Timezones/TimezonesPicker";
import ThemeControl from "../ThemeControl/ThemeControl";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import useBoolean from "../../../hooks/useBoolean";
import BrowserTabController from "./BrowserTabController/BrowserTabController";
import LegendCollapseController from "./LegendCollapseController/LegendCollapseController";
const title = "Settings";
@@ -26,7 +28,6 @@ const GlobalSettings: FC = () => {
const serverSettingRef = useRef<ChildComponentHandle>(null);
const limitsSettingRef = useRef<ChildComponentHandle>(null);
const timezoneSettingRef = useRef<ChildComponentHandle>(null);
const {
value: open,
@@ -37,7 +38,6 @@ const GlobalSettings: FC = () => {
const handleApply = () => {
serverSettingRef.current && serverSettingRef.current.handleApply();
limitsSettingRef.current && limitsSettingRef.current.handleApply();
timezoneSettingRef.current && timezoneSettingRef.current.handleApply();
handleClose();
};
@@ -49,6 +49,10 @@ const GlobalSettings: FC = () => {
onClose={handleClose}
/>
},
{
show: true,
component: <TimezonesPicker/>
},
{
show: true,
component: <LimitsConfigurator
@@ -58,12 +62,16 @@ const GlobalSettings: FC = () => {
},
{
show: true,
component: <Timezones ref={timezoneSettingRef}/>
component: <LegendCollapseController/>
},
{
show: !appModeEnable,
component: <ThemeControl/>
}
},
{
show: true,
component: <BrowserTabController/>
},
].filter(control => control.show);
return <>

View File

@@ -0,0 +1,30 @@
import { FC, useEffect, useState } from "preact/compat";
import { getFromStorage, saveToStorage } from "../../../../utils/storage";
import Switch from "../../../Main/Switch/Switch";
import { LEGEND_COLLAPSE_SERIES_LIMIT } from "../../../../constants/graph";
import "./style.scss";
const LegendCollapseController: FC = () => {
const storageCollapse = getFromStorage("LEGEND_AUTO_COLLAPSE");
const [legendCollapse, setLegendCollapse] = useState(storageCollapse ? storageCollapse === "true" : true);
useEffect(() => {
saveToStorage("LEGEND_AUTO_COLLAPSE", `${legendCollapse}`);
}, [legendCollapse]);
return (
<div className="vm-legend-collapse-controller">
<Switch
fullWidth
color="neutral"
value={legendCollapse}
onChange={setLegendCollapse}
label={<span className="vm-server-configurator__title">Auto-collapse legend</span>}
/>
<span className="vm-legend-collapse-controller__description">
Collapses the legend when series count exceeds {LEGEND_COLLAPSE_SERIES_LIMIT} to reduce UI load.
</span>
</div>);
};
export default LegendCollapseController;

View File

@@ -0,0 +1,19 @@
@use "src/styles/variables" as *;
.vm-legend-collapse-controller {
background-color: $color-hover-black;
border-radius: $border-radius-medium;
padding: $padding-large;
border: $border-divider;
.vm-graph-settings-row__label {
margin: 0;
}
&__description {
padding-top: $padding-global;
font-size: $font-size-small;
line-height: 1.3;
text-wrap: pretty;
}
}

View File

@@ -1,23 +1,21 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "preact/compat";
import { forwardRef, useCallback, useImperativeHandle, useState } from "preact/compat";
import { DisplayType, ErrorTypes } from "../../../../types";
import TextField from "../../../Main/TextField/TextField";
import Tooltip from "../../../Main/Tooltip/Tooltip";
import { InfoIcon, RestartIcon } from "../../../Main/Icons";
import Button from "../../../Main/Button/Button";
import { DEFAULT_MAX_SERIES, LEGEND_COLLAPSE_SERIES_LIMIT } from "../../../../constants/graph";
import { DEFAULT_MAX_SERIES } from "../../../../constants/graph";
import "./style.scss";
import classNames from "classnames";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
import { ChildComponentHandle } from "../GlobalSettings";
import { useCustomPanelDispatch, useCustomPanelState } from "../../../../state/customPanel/CustomPanelStateContext";
import Switch from "../../../Main/Switch/Switch";
import { getFromStorage, saveToStorage } from "../../../../utils/storage";
interface ServerConfiguratorProps {
onClose: () => void
onClose: () => void;
}
const fields: {label: string, type: DisplayType}[] = [
const fields: { label: string, type: DisplayType }[] = [
{ label: "Graph", type: DisplayType.chart },
{ label: "JSON", type: DisplayType.code },
{ label: "Table", type: DisplayType.table }
@@ -29,8 +27,7 @@ const LimitsConfigurator = forwardRef<ChildComponentHandle, ServerConfiguratorPr
const { seriesLimits } = useCustomPanelState();
const customPanelDispatch = useCustomPanelDispatch();
const storageCollapse = getFromStorage("LEGEND_AUTO_COLLAPSE");
const [legendCollapse, setLegendCollapse] = useState(storageCollapse ? storageCollapse === "true" : true);
const [limits, setLimits] = useState(seriesLimits);
const [error, setError] = useState({
@@ -43,7 +40,7 @@ const LimitsConfigurator = forwardRef<ChildComponentHandle, ServerConfiguratorPr
setLimits(DEFAULT_MAX_SERIES);
};
const createChangeHandler = (type: DisplayType) => (val: string) => {
const createChangeHandler = (type: DisplayType) => (val: string) => {
const value = val || "";
setError(prev => ({ ...prev, [type]: +value < 0 ? ErrorTypes.positiveNumber : "" }));
setLimits({
@@ -57,10 +54,6 @@ const LimitsConfigurator = forwardRef<ChildComponentHandle, ServerConfiguratorPr
onClose();
}, [limits]);
useEffect(() => {
saveToStorage("LEGEND_AUTO_COLLAPSE", `${legendCollapse}`);
}, [legendCollapse]);
useImperativeHandle(ref, () => ({ handleApply }), [handleApply]);
return (
@@ -106,19 +99,6 @@ const LimitsConfigurator = forwardRef<ChildComponentHandle, ServerConfiguratorPr
</div>
))}
</div>
<div className="vm-graph-settings-row">
<span className="vm-graph-settings-row__label">Auto-collapse legend</span>
<Switch
value={legendCollapse}
onChange={setLegendCollapse}
label={legendCollapse ? "Enabled" : "Disabled"}
fullWidth={isMobile}
/>
<span className="vm-legend-configs-item__info">
Collapses the legend when series count exceeds {LEGEND_COLLAPSE_SERIES_LIMIT} to reduce UI load.
</span>
</div>
</div>
);
});

View File

@@ -1,183 +0,0 @@
import { FC, forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "preact/compat";
import { getBrowserTimezone, getTimezoneList, getUTCByTimezone } from "../../../../utils/time";
import { ArrowDropDownIcon } from "../../../Main/Icons";
import classNames from "classnames";
import Popper from "../../../Main/Popper/Popper";
import Accordion from "../../../Main/Accordion/Accordion";
import TextField from "../../../Main/TextField/TextField";
import { Timezone } from "../../../../types";
import "./style.scss";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
import useBoolean from "../../../../hooks/useBoolean";
import WarningTimezone from "./WarningTimezone";
import { useTimeDispatch, useTimeState } from "../../../../state/time/TimeStateContext";
interface PinnedTimezone extends Timezone {
title: string;
isInvalid?: boolean;
}
const browserTimezone = getBrowserTimezone();
const Timezones: FC = forwardRef((props, ref) => {
const { isMobile } = useDeviceDetect();
const timezones = getTimezoneList();
const { timezone: stateTimezone, defaultTimezone } = useTimeState();
const timeDispatch = useTimeDispatch();
const [timezone, setTimezone] = useState(stateTimezone);
const [search, setSearch] = useState("");
const targetRef = useRef<HTMLDivElement>(null);
const {
value: openList,
toggle: toggleOpenList,
setFalse: handleCloseList,
} = useBoolean(false);
const pinnedTimezones = useMemo(() => [
{
title: `Default time (${defaultTimezone})`,
region: defaultTimezone,
utc: defaultTimezone ? getUTCByTimezone(defaultTimezone) : "UTC"
},
{
title: browserTimezone.title,
region: browserTimezone.region,
utc: getUTCByTimezone(browserTimezone.region),
isInvalid: !browserTimezone.isValid
},
{
title: "UTC (Coordinated Universal Time)",
region: "UTC",
utc: "UTC"
},
].filter(t => t.region) as PinnedTimezone[], [defaultTimezone]);
const searchTimezones = useMemo(() => {
if (!search) return timezones;
try {
return getTimezoneList(search);
} catch (e) {
return {};
}
}, [search, timezones]);
const timezonesGroups = useMemo(() => Object.keys(searchTimezones), [searchTimezones]);
const activeTimezone = useMemo(() => ({
region: timezone,
utc: getUTCByTimezone(timezone)
}), [timezone]);
const handleChangeSearch = (val: string) => {
setSearch(val);
};
const handleSetTimezone = (val: Timezone) => {
setTimezone(val.region);
setSearch("");
handleCloseList();
};
const createHandlerSetTimezone = (val: Timezone) => () => {
handleSetTimezone(val);
};
useEffect(() => {
setTimezone(stateTimezone);
}, [stateTimezone]);
useImperativeHandle(ref, () => ({
handleApply: () => {
timeDispatch({ type: "SET_TIMEZONE", payload: timezone });
}
}), [timezone]);
return (
<div className="vm-timezones">
<div className="vm-server-configurator__title">
Time zone
</div>
<div
className="vm-timezones-item vm-timezones-item_selected"
onClick={toggleOpenList}
ref={targetRef}
>
<div className="vm-timezones-item__title">{activeTimezone.region}</div>
<div className="vm-timezones-item__utc">{activeTimezone.utc}</div>
<div
className={classNames({
"vm-timezones-item__icon": true,
"vm-timezones-item__icon_open": openList
})}
>
<ArrowDropDownIcon/>
</div>
</div>
<Popper
open={openList}
buttonRef={targetRef}
placement="bottom-left"
onClose={handleCloseList}
fullWidth
title={isMobile ? "Time zone" : undefined}
>
<div
className={classNames({
"vm-timezones-list": true,
"vm-timezones-list_mobile": isMobile,
})}
>
<div className="vm-timezones-list-header">
<div className="vm-timezones-list-header__search">
<TextField
autofocus
label="Search"
value={search}
onChange={handleChangeSearch}
/>
</div>
{pinnedTimezones.map((t, i) => t && (
<div
key={`${i}_${t.region}`}
className="vm-timezones-item vm-timezones-list-group-options__item"
onClick={createHandlerSetTimezone(t)}
>
<div className="vm-timezones-item__title">{t.title}{t.isInvalid && <WarningTimezone/>}</div>
<div className="vm-timezones-item__utc">{t.utc}</div>
</div>
))}
</div>
{timezonesGroups.map(t => (
<div
className="vm-timezones-list-group"
key={t}
>
<Accordion
defaultExpanded={true}
title={<div className="vm-timezones-list-group__title">{t}</div>}
>
<div className="vm-timezones-list-group-options">
{searchTimezones[t] && searchTimezones[t].map(item => (
<div
className="vm-timezones-item vm-timezones-list-group-options__item"
onClick={createHandlerSetTimezone(item)}
key={item.search}
>
<div className="vm-timezones-item__title">{item.region}</div>
<div className="vm-timezones-item__utc">{item.utc}</div>
</div>
))}
</div>
</Accordion>
</div>
))}
</div>
</Popper>
</div>
);
});
export default Timezones;

View File

@@ -0,0 +1,129 @@
import { FC, useMemo, useState } from "preact/compat";
import { getBrowserTimezone, getTimezoneList, getUTCByTimezone } from "../../../../utils/time";
import classNames from "classnames";
import Accordion from "../../../Main/Accordion/Accordion";
import TextField from "../../../Main/TextField/TextField";
import { Timezone } from "../../../../types";
import "./style.scss";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
import WarningTimezone from "./WarningTimezone";
import { useTimeState } from "../../../../state/time/TimeStateContext";
interface PinnedTimezone extends Timezone {
title: string;
isInvalid?: boolean;
}
type Props = {
onChange: (tz: Timezone) => void;
}
const browserTimezone = getBrowserTimezone();
const TimezonesList: FC<Props> = ({ onChange }) => {
const { isMobile } = useDeviceDetect();
const { defaultTimezone } = useTimeState();
const timezones = useMemo(() => getTimezoneList(), []);
const [search, setSearch] = useState("");
const pinnedTimezones = useMemo(() => [
{
title: `Default time (${defaultTimezone})`,
region: defaultTimezone,
utc: defaultTimezone ? getUTCByTimezone(defaultTimezone) : "UTC"
},
{
title: browserTimezone.title,
region: browserTimezone.region,
utc: getUTCByTimezone(browserTimezone.region),
isInvalid: !browserTimezone.isValid
},
{
title: "UTC (Coordinated Universal Time)",
region: "UTC",
utc: "UTC"
},
].filter(t => t.region) as PinnedTimezone[], [defaultTimezone]);
const searchTimezones = useMemo(() => {
if (!search) return timezones;
try {
return getTimezoneList(search);
} catch (e) {
return {};
}
}, [search, timezones]);
const timezonesGroups = useMemo(() => Object.keys(searchTimezones), [searchTimezones]);
const handleChangeSearch = (val: string) => {
setSearch(val);
};
const handleSetTimezone = (tz: Timezone) => {
onChange(tz);
setSearch("");
};
const createHandlerSetTimezone = (val: Timezone) => () => {
handleSetTimezone(val);
};
return (
<div
className={classNames({
"vm-list": true,
"vm-timezones-list": true,
"vm-timezones-list_mobile": isMobile,
})}
>
<div className="vm-timezones-list-header">
<div className="vm-timezones-list-header__search">
<TextField
label="Search"
value={search}
onChange={handleChangeSearch}
/>
</div>
</div>
{pinnedTimezones.map((t, i) => t && (
<div
key={`${i}_${t.region}`}
className="vm-list-item vm-timezones-item vm-timezones-list-group-options__item"
onClick={createHandlerSetTimezone(t)}
>
<div className="vm-timezones-item__title">{t.title}{t.isInvalid && <WarningTimezone/>}</div>
<div className="vm-timezones-item__utc">{t.utc}</div>
</div>
))}
{timezonesGroups.map(t => (
<div
className="vm-timezones-list-group"
key={t}
>
<Accordion
defaultExpanded={true}
title={<div className="vm-timezones-list-group__title">{t}</div>}
>
<div className="vm-timezones-list-group-options">
{searchTimezones[t] && searchTimezones[t].map(item => (
<div
className="vm-list-item vm-timezones-item vm-timezones-list-group-options__item"
onClick={createHandlerSetTimezone(item)}
key={item.search}
>
<div className="vm-timezones-item__title">{item.region}</div>
<div className="vm-timezones-item__utc">{item.utc}</div>
</div>
))}
</div>
</Accordion>
</div>
))}
</div>
);
};
export default TimezonesList;

View File

@@ -0,0 +1,71 @@
import { FC, useMemo, useRef } from "preact/compat";
import { getUTCByTimezone } from "../../../../utils/time";
import { ArrowDropDownIcon } from "../../../Main/Icons";
import classNames from "classnames";
import { Timezone } from "../../../../types";
import "./style.scss";
import useBoolean from "../../../../hooks/useBoolean";
import { useTimeDispatch, useTimeState } from "../../../../state/time/TimeStateContext";
import TimezonesList from "./TimezonesList";
import Popper from "../../../Main/Popper/Popper";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
const TimezonesPicker: FC = () => {
const { isMobile } = useDeviceDetect();
const { timezone: stateTimezone } = useTimeState();
const timeDispatch = useTimeDispatch();
const triggerRef = useRef<HTMLDivElement>(null);
const {
value: isOpenList,
toggle: toggleOpenList,
setFalse: handleCloseList,
} = useBoolean(false);
const activeTimezone = useMemo(() => ({
region: stateTimezone,
utc: getUTCByTimezone(stateTimezone)
}), [stateTimezone]);
const handleSetTimezone = (tz: Timezone) => {
timeDispatch({ type: "SET_TIMEZONE", payload: tz.region });
handleCloseList();
};
return (
<div className="vm-timezones">
<div className="vm-server-configurator__title">
Time zone
</div>
<div
className="vm-timezones-item vm-timezones-item_selected"
onClick={toggleOpenList}
ref={triggerRef}
>
<div className="vm-timezones-item__title">{activeTimezone.region}</div>
<div className="vm-timezones-item__utc">{activeTimezone.utc}</div>
<div
className={classNames({
"vm-timezones-item__icon": true,
"vm-timezones-item__icon_open": isOpenList
})}
>
<ArrowDropDownIcon/>
</div>
</div>
<Popper
open={isOpenList}
buttonRef={triggerRef}
placement="bottom-left"
onClose={handleCloseList}
fullWidth
title={isMobile ? "Time zone" : undefined}
>
<TimezonesList onChange={handleSetTimezone}/>
</Popper>
</div>
);
};
export default TimezonesPicker;

View File

@@ -16,6 +16,7 @@
}
&__title {
flex-grow: 1;
display: flex;
align-items: center;
gap: $padding-small;
@@ -34,6 +35,7 @@
background-color: $color-hover-black;
padding: calc($padding-small/2);
border-radius: $border-radius-small;
font-size: $font-size-small;
}
&__icon {
@@ -54,9 +56,11 @@
}
&-list {
padding-top: 0;
max-height: 300px;
background-color: $color-background-block;
border-radius: $border-radius-medium;
font-size: $font-size-small;
overflow: auto;
&_mobile {
@@ -72,10 +76,9 @@
top: 0;
background-color: $color-background-block;
z-index: 2;
border-bottom: $border-divider;
&__search {
padding: $padding-small;
padding: $padding-small $padding-small calc($padding-small / 2);
}
}
@@ -91,6 +94,7 @@
font-weight: bold;
color: $color-text-secondary;
padding: $padding-small $padding-global;
font-size: $font-size-small;
}
&-options {
@@ -98,7 +102,7 @@
align-items: flex-start;
&__item {
padding: $padding-small $padding-global;
padding: calc($padding-small / 2) $padding-global;
transition: background-color 200ms ease;
&:hover {

View File

@@ -4,9 +4,9 @@
display: flex;
flex-direction: column;
align-items: center;
gap: $padding-large;
gap: calc($padding-global * 2);
width: 600px;
padding-bottom: $padding-medium;
padding-inline: $padding-large;
&_mobile {
grid-auto-rows: min-content;
@@ -62,6 +62,7 @@
justify-content: flex-end;
gap: $padding-small;
width: 100%;
padding-block: $padding-global;
}
&_mobile &-footer {

View File

@@ -22,12 +22,10 @@ const StepConfigurator: FC = () => {
const { isMobile } = useDeviceDetect();
const { customStep: value, isHistogram } = useGraphState();
const { period: { step, end, start } } = useTimeState();
const { period: { end, start } } = useTimeState();
const graphDispatch = useGraphDispatch();
const { displayType } = useCustomPanelState();
const prevDuration = usePrevious(end - start);
const defaultStep = useMemo(() => {
return getStepFromDuration(end - start, isHistogram, displayType);
}, [end, start, isHistogram, displayType]);
@@ -106,16 +104,14 @@ const StepConfigurator: FC = () => {
}, [defaultStep]);
useEffect(() => {
const dur = end - start;
if (dur === prevDuration || !prevDuration || value !== prevDefaultStep) return;
if (defaultStep) {
handleApply(defaultStep);
}
}, [prevDuration, defaultStep]);
if (!prevDefaultStep) return;
if (value !== prevDefaultStep) return;
if (value === defaultStep) return;
useEffect(() => {
if (step === value || step === defaultStep) handleApply(defaultStep);
}, [isHistogram, displayType]);
graphDispatch({ type: "SET_CUSTOM_STEP", payload: defaultStep });
setCustomStep(defaultStep);
setError("");
}, [defaultStep, prevDefaultStep, value, graphDispatch]);
return (
<div

View File

@@ -5,8 +5,20 @@ import useDeviceDetect from "../../../hooks/useDeviceDetect";
import classNames from "classnames";
import { FC } from "preact/compat";
import { useAppDispatch, useAppState } from "../../../state/common/StateContext";
import { DarkIcon, LightIcon, SystemIcon } from "../../Main/Icons";
const themeIcons = {
[Theme.system]: <SystemIcon/>,
[Theme.light]: <LightIcon/>,
[Theme.dark]: <DarkIcon/>,
};
const options = Object.values(Theme).map(value => ({
title: value,
value,
icon: themeIcons[value],
}));
const options = Object.values(Theme).map(value => ({ title: value, value }));
const ThemeControl: FC = () => {
const { isMobile } = useDeviceDetect();
const dispatch = useAppDispatch();
@@ -25,13 +37,14 @@ const ThemeControl: FC = () => {
})}
>
<div className="vm-server-configurator__title">
Theme preferences
Theme
</div>
<div
className="vm-theme-control__toggle"
key={`${isMobile}`}
>
<Toggle
size="large"
options={options}
value={theme}
onChange={handleClickItem}

View File

@@ -4,7 +4,7 @@
&__toggle {
display: inline-flex;
min-width: 300px;
width: 100%;
text-transform: capitalize;
}

View File

@@ -633,3 +633,60 @@ export const DebugIcon = () => (
/>
</svg>
);
export const SystemIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M18 4C18.7957 4 19.5595 4.3163 20.1221 4.87891C20.6845 5.44148 21 6.20452 21 7V15.5264L21.0069 15.6426C21.0203 15.7579 21.0542 15.8702 21.1065 15.9746L22.1729 18.0996C22.3271 18.4056 22.4009 18.7466 22.3858 19.0889C22.3705 19.431 22.2675 19.7637 22.0869 20.0547C21.9063 20.3457 21.6534 20.5855 21.3535 20.751C21.0555 20.9154 20.7202 21.0003 20.3799 20.999L3.62013 21C3.28002 21.0012 2.94535 20.9153 2.64748 20.751C2.34748 20.5855 2.09477 20.3458 1.91408 20.0547C1.73343 19.7636 1.63047 19.4311 1.61525 19.0889C1.60006 18.7466 1.67297 18.4056 1.82716 18.0996L2.89455 15.9746L2.94045 15.8682C2.9801 15.7589 3.00007 15.6432 3.00002 15.5264V7C3.00002 6.20442 3.3164 5.4415 3.87892 4.87891C4.44146 4.31636 5.20447 4.00007 6.00002 4H18ZM4.62404 16.9873L3.61427 18.999L3.6133 19H20.3877L20.3867 18.999L19.376 16.9873H4.62404ZM6.00002 6C5.7349 6.00007 5.48045 6.1055 5.29298 6.29297C5.10554 6.48049 5.00002 6.73485 5.00002 7V14.9873H19V7C19 6.73478 18.8946 6.48051 18.707 6.29297C18.5195 6.10552 18.2652 6 18 6H6.00002Z"
/>
</svg>
);
export const LightIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 19C12.5523 19 13 19.4477 13 20V22C13 22.5523 12.5523 23 12 23C11.4477 23 11 22.5523 11 22V20C11 19.4477 11.4477 19 12 19Z"
/>
<path
d="M5.63281 16.9531C6.02334 16.5627 6.65638 16.5626 7.04688 16.9531C7.43717 17.3436 7.43725 17.9767 7.04688 18.3672L5.63672 19.7773C5.24625 20.1676 4.61313 20.1676 4.22266 19.7773C3.8322 19.3869 3.83233 18.7538 4.22266 18.3633L5.63281 16.9531Z"
/>
<path
d="M16.9531 16.9531C17.3436 16.5626 17.9767 16.5626 18.3672 16.9531L19.7773 18.3633C20.1676 18.7538 20.1678 19.3869 19.7773 19.7773C19.3869 20.1677 18.7538 20.1675 18.3633 19.7773L16.9531 18.3672C16.5626 17.9767 16.5627 17.3437 16.9531 16.9531Z"
/>
<path
d="M12 7C14.7614 7 17 9.23858 17 12C17 14.7614 14.7614 17 12 17C9.23858 17 7 14.7614 7 12C7 9.23858 9.23858 7 12 7ZM12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9Z"
/>
<path
d="M4 11C4.55228 11 5 11.4477 5 12C5 12.5523 4.55228 13 4 13H2C1.44772 13 1 12.5523 1 12C1 11.4477 1.44772 11 2 11H4Z"
/>
<path
d="M22 11C22.5523 11 23 11.4477 23 12C23 12.5523 22.5523 13 22 13H20C19.4477 13 19 12.5523 19 12C19 11.4477 19.4477 11 20 11H22Z"
/>
<path
d="M4.22266 4.22266C4.61315 3.83229 5.24623 3.83229 5.63672 4.22266L7.04688 5.63281C7.4372 6.02331 7.43723 6.65639 7.04688 7.04688C6.6564 7.43735 6.02335 7.43724 5.63281 7.04688L4.22266 5.63672C3.83225 5.24618 3.83217 4.61314 4.22266 4.22266Z"
/>
<path
d="M18.3633 4.22266C18.7538 3.83237 19.3869 3.83232 19.7773 4.22266C20.1677 4.61312 20.1676 5.2462 19.7773 5.63672L18.3672 7.04688C17.9767 7.4373 17.3436 7.4373 16.9531 7.04688C16.5627 6.65637 16.5627 6.0233 16.9531 5.63281L18.3633 4.22266Z"
/>
<path
d="M12 1C12.5523 1 13 1.44772 13 2V4C13 4.55228 12.5523 5 12 5C11.4477 5 11 4.55228 11 4V2C11 1.44772 11.4477 1 12 1Z"
/>
</svg>
);
export const DarkIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M11.5809 2.01318C12.1851 2.02907 12.6373 2.40742 12.8475 2.84717C13.0627 3.29758 13.0619 3.86893 12.7615 4.34814L12.7606 4.34717C12.1616 5.30581 11.9061 6.43987 12.034 7.56299C12.162 8.68628 12.6672 9.73328 13.4666 10.5327C14.2661 11.332 15.3131 11.8364 16.4363 11.9644C17.5596 12.0922 18.6934 11.836 19.6522 11.2368L19.8348 11.1382C20.2701 10.9405 20.7563 10.9617 21.1512 11.1499C21.6217 11.3742 22.0194 11.8752 21.9832 12.5405C21.8789 14.4693 21.2181 16.3268 20.0809 17.8882C18.9435 19.4496 17.378 20.6484 15.574 21.3394C13.7701 22.0302 11.8042 22.184 9.91485 21.7817C8.02549 21.3794 6.29356 20.4376 4.92754 19.0718C3.56149 17.7059 2.62012 15.9739 2.21758 14.0845C1.81507 12.195 1.96826 10.2294 2.65899 8.42529C3.34975 6.62115 4.5487 5.05596 6.11016 3.91846C7.6716 2.781 9.52882 2.11947 11.4578 2.01514L11.5809 2.01318ZM10.6209 4.12061C9.42038 4.33038 8.27873 4.81214 7.28692 5.53467C6.03798 6.44459 5.07973 7.69705 4.52715 9.14014C3.97456 10.5834 3.85165 12.156 4.17364 13.6675C4.49565 15.179 5.24872 16.5649 6.34161 17.6577C7.43448 18.7505 8.82026 19.5038 10.3318 19.8257C11.8434 20.1475 13.416 20.0239 14.8592 19.4712C16.3024 18.9184 17.5548 17.9597 18.4647 16.7104C19.1869 15.7188 19.6671 14.5776 19.8768 13.3774C18.7333 13.8927 17.4674 14.0949 16.2098 13.9517C14.637 13.7725 13.1709 13.0661 12.0516 11.9468C10.9324 10.8275 10.2258 9.36126 10.0467 7.78857C9.90352 6.53075 10.1054 5.26417 10.6209 4.12061Z"
/>
</svg>
);

View File

@@ -1,11 +1,10 @@
import { ReactNode } from "react";
import { FC, ReactNode } from "preact/compat";
import classNames from "classnames";
import "./style.scss";
import { FC } from "preact/compat";
interface SwitchProps {
value: boolean
color?: "primary" | "secondary" | "error"
color?: "primary" | "secondary" | "error" | "neutral"
disabled?: boolean
label?: string | ReactNode
fullWidth?: boolean

View File

@@ -29,6 +29,10 @@ $switch-border-radius: $switch-handle-size + ($switch-padding * 2);
background-color: $color-secondary;
}
&_neutral_active &-track {
background-color: $color-text;
}
&_primary_active &-track {
background-color: $color-primary;
}

View File

@@ -1,22 +1,21 @@
import { FC, useEffect, useRef, useState } from "preact/compat";
import { FC, useEffect, useRef, useState, ReactNode } from "preact/compat";
import classNames from "classnames";
import { ReactNode } from "react";
import "./style.scss";
interface ToggleProps {
options: {value: string, title?: string, icon?: ReactNode}[]
value: string
onChange: (val: string) => void
label?: string
options: { value: string, title?: string, icon?: ReactNode }[];
value: string;
onChange: (val: string) => void;
label?: string;
size?: "medium" | "large";
}
const Toggle: FC<ToggleProps> = ({ options, value, label, onChange }) => {
const Toggle: FC<ToggleProps> = ({ options, value, label, size = "medium", onChange }) => {
const activeRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({
width: "0px",
left: "0px",
borderRadius: "0px"
});
const createHandlerChange = (value: string) => () => {
@@ -28,35 +27,25 @@ const Toggle: FC<ToggleProps> = ({ options, value, label, onChange }) => {
setPosition({
width: "0px",
left: "0px",
borderRadius: "0px"
});
return;
}
const index = options.findIndex(o => o.value === value);
const { width: widthRect } = activeRef.current.getBoundingClientRect();
let width = widthRect;
let left = index * width;
let borderRadius = "0";
if (index === 0) borderRadius = "16px 0 0 16px";
const width = widthRect;
const left = index * width;
if (index === options.length - 1) {
borderRadius = "10px";
left -= 1;
borderRadius = "0 16px 16px 0";
}
if (index !== 0 && (index !== options.length - 1)) {
width += 1;
left -= 1;
}
setPosition({ width: `${width}px`, left: `${left}px`, borderRadius });
setPosition({ width: `${width}px`, left: `${left}px` });
}, [activeRef, value, options]);
return (
<div className="vm-toggles">
<div
className={classNames({
"vm-toggles": true,
[`vm-toggles_${size}`]: size,
})}
>
{label && (
<label className="vm-toggles__label">
{label}
@@ -66,15 +55,14 @@ const Toggle: FC<ToggleProps> = ({ options, value, label, onChange }) => {
className="vm-toggles-group"
style={{ gridTemplateColumns: `repeat(${options.length}, 1fr)` }}
>
{position.borderRadius && <div
<div
className="vm-toggles-group__highlight"
style={position}
/>}
{options.map((option, i) => (
/>
{options.map((option) => (
<div
className={classNames({
"vm-toggles-group-item": true,
"vm-toggles-group-item_first": i === 0,
"vm-toggles-group-item_active": option.value === value,
"vm-toggles-group-item_icon": option.icon && option.title
})}

View File

@@ -20,6 +20,8 @@
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: $border-radius-small;
background: $color-hover-black;
&-item {
position: relative;
@@ -27,55 +29,68 @@
align-items: center;
justify-content: center;
padding: $padding-small;
border-right: $border-divider;
border-top: $border-divider;
border-bottom: $border-divider;
font-size: $font-size-small;
color: $color-text-secondary;
font-weight: bold;
font-weight: 500;
cursor: pointer;
text-align: center;
transition: color 150ms ease-in;
transition: opacity 150ms ease-in, color 150ms ease-in;
z-index: 2;
user-select: none;
&_first {
border-radius: 16px 0 0 16px;
border-left: $border-divider
}
&:last-child {
border-radius: 0 16px 16px 0;
border-left: none;
}
&_icon {
grid-template-columns: 14px auto;
gap: 4px;
gap: calc($padding-small / 2);
}
&:hover {
color: $color-primary;
&:hover:not(&_active) {
opacity: 0.8;
}
&_active {
color: $color-primary;
border-color: transparent;
&:hover {
background-color: transparent;
}
color: $color-text;
font-weight: 600;
}
}
&__highlight {
position: absolute;
top: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 3px;
height: 100%;
background-color: rgba($color-primary, 0.08);
border: 1px solid $color-primary;
transition: left 200ms cubic-bezier(0.280, 0.840, 0.420, 1), border-radius 200ms linear;
z-index: 1;
&:after {
content: '';
height: 100%;
width: 100%;
background-color: $color-background-block;
border-radius: $border-radius-small;
box-shadow: $box-shadow;
}
}
}
&_large &-group {
border-radius: $border-radius-medium;
&-item {
padding: $padding-global $padding-small;
&_icon {
grid-template-columns: 16px auto;
gap: $padding-small;
}
}
&__highlight {
padding: 4px;
border-radius: $border-radius-medium;
}
}
}

View File

@@ -0,0 +1,14 @@
export const faviconColors = [
"#A1A1AA",
"#71717A",
"#020202",
"#E94600",
"#FF7A00",
"#F2B705",
"#84CC16",
"#16B86A",
"#00AFAF",
"#2979FF",
"#8B5CF6",
"#E83E9A",
] as const;

View File

@@ -14,6 +14,9 @@ import useFetchDefaultTimezone from "../../hooks/useFetchDefaultTimezone";
import useFetchAppConfig from "../../hooks/useFetchAppConfig";
import WebStorageCheck from "../../components/WebStorageCheck/WebStorageCheck";
import { migrateStorageToPrefixedKeys } from "../../utils/storage";
import {
useBrowserTabSync
} from "../../components/Configurators/GlobalSettings/BrowserTabController/hooks/useBrowserTabSync";
const MainLayout: FC = () => {
const appModeEnable = getAppModeEnable();
@@ -21,6 +24,7 @@ const MainLayout: FC = () => {
const { pathname } = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
useBrowserTabSync();
useFetchDashboards();
useFetchDefaultTimezone();
useFetchAppConfig();

View File

@@ -0,0 +1,29 @@
import faviconRaw from "../../assets/favicon.svg?raw";
export const createFaviconUrl = (color = "#020202"): string => {
const svgDocument = new DOMParser().parseFromString(faviconRaw, "image/svg+xml");
const svg = svgDocument.documentElement;
if (svg.localName !== "svg") {
throw new Error("Invalid favicon SVG");
}
svg.setAttribute("fill", color);
const serializedSvg = new XMLSerializer().serializeToString(svg);
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serializedSvg)}`;
};
export const updateFaviconColor = (color = "#020202"): void => {
const favicon = document.querySelector<HTMLLinkElement>("#favicon");
if (favicon) {
favicon.href = createFaviconUrl(color);
}
const maskIcon = document.querySelector<HTMLLinkElement>("#mask-icon");
if (maskIcon) {
maskIcon.setAttribute("color", color);
}
};
export const getFaviconStorageKey = () => window.location.pathname.replace(/\/+$/, "") || "/";

View File

@@ -17,7 +17,11 @@ export const ALL_STORAGE_KEYS = [
"POINTS_SHOW_ALL",
] as const;
export type StorageKeys = (typeof ALL_STORAGE_KEYS)[number];
export type FaviconStorageKey = `FAVICON_COLOR:${string}`;
export type StorageKeys =
| (typeof ALL_STORAGE_KEYS)[number]
| FaviconStorageKey;
type PrefixedStorageKeys = `${typeof STORAGE_PREFIX}${StorageKeys}`;
@@ -58,7 +62,10 @@ export const getFromStorage = (key: StorageKeys, withPrefix = true): undefined |
export const removeFromStorage = (keys: StorageKeys[], withPrefix = true): void => {
const storageKeys = withPrefix ? keys.map(toPrefixedKey) : keys;
storageKeys.forEach(k => window.localStorage.removeItem(k));
storageKeys.forEach(k => {
window.localStorage.removeItem(k);
window.dispatchEvent(new StorageEvent("storage", { key: k }));
});
};
/**

View File

@@ -205,19 +205,21 @@ export const getUTCByTimezone = (timezone: string) => {
};
export const getTimezoneList = (search = "") => {
const regexp = new RegExp(search, "i");
const normalizedSearch = search.toLowerCase();
return supportedTimezones.reduce((acc: {[key: string]: Timezone[]}, region) => {
return supportedTimezones.reduce((acc: { [key: string]: Timezone[] }, region) => {
const zone = (region.match(/^(.*?)\//) || [])[1] || "unknown";
const utc = getUTCByTimezone(region);
const utcForSearch = utc.replace(/UTC|0/, "");
const utcForSearch = utc.replace(/^UTC/, "");
const regionForSearch = region.replace(/[/_]/g, " ");
const item = {
region,
utc,
search: `${region} ${utc} ${regionForSearch} ${utcForSearch}`
};
const includeZone = !search || (search && regexp.test(item.search));
const includeZone = !normalizedSearch || item.search.toLowerCase().includes(normalizedSearch);
if (includeZone && acc[zone]) {
acc[zone].push(item);

View File

@@ -50,6 +50,13 @@ export default defineConfig(() => {
return "vendor";
}
},
assetFileNames: (assetInfo) => {
if (assetInfo.names.includes("favicon.svg")) {
return "assets/favicon.svg";
}
return "assets/[name]-[hash][extname]";
},
},
},
},

View File

@@ -135,7 +135,7 @@ func tenantViaURL(addr, prefix, tenant, suffix string) string {
}
// tenantViaHeaders returns path in cluster's URL format where tenant is omitted in URL
// Only supported if -enableMultitenancyViaHeaders is specified
// Only supported if -enableMultitenancyViaHeaders is enabled
func tenantViaHeaders(addr, prefix, suffix string) string {
return fmt.Sprintf("http://%s/%s/%s", addr, prefix, suffix)
}

View File

@@ -25,12 +25,10 @@ func TestClusterMultiTenantSelectViaHeaders(t *testing.T) {
})
vminsert := tc.MustStartVminsert("vminsert", []string{
"-storageNode=" + vmstorage.VminsertAddr(),
"-enableMultitenancyViaHeaders",
})
vmselect := tc.MustStartVmselect("vmselect", []string{
"-storageNode=" + vmstorage.VmselectAddr(),
"-search.tenantCacheExpireDuration=0",
"-enableMultitenancyViaHeaders",
})
multitenant := make(http.Header)

View File

@@ -26,33 +26,20 @@ func TestClusterSearchWithDisabledPerDayIndex(t *testing.T) {
defer tc.Stop()
testSearchWithDisabledPerDayIndex(tc, func(name string, disablePerDayIndex bool) apptest.PrometheusWriteQuerier {
// Using static ports for vmstorage because random ports may cause
// changes in how data is sharded.
vmstorage1 := tc.MustStartVmstorage("vmstorage1-"+name, []string{
"-storageDataPath=" + tc.Dir() + "/vmstorage1",
vmstorage := tc.MustStartVmstorage("vmstorage-"+name, []string{
"-storageDataPath=" + tc.Dir() + "/vmstorage",
"-retentionPeriod=100y",
"-httpListenAddr=127.0.0.1:61001",
"-vminsertAddr=127.0.0.1:61002",
"-vmselectAddr=127.0.0.1:61003",
fmt.Sprintf("-disablePerDayIndex=%t", disablePerDayIndex),
})
vmstorage2 := tc.MustStartVmstorage("vmstorage2-"+name, []string{
"-storageDataPath=" + tc.Dir() + "/vmstorage2",
"-retentionPeriod=100y",
"-httpListenAddr=127.0.0.1:62001",
"-vminsertAddr=127.0.0.1:62002",
"-vmselectAddr=127.0.0.1:62003",
fmt.Sprintf("-disablePerDayIndex=%t", disablePerDayIndex),
})
vminsert := tc.MustStartVminsert("vminsert-"+name, []string{
"-storageNode=" + vmstorage1.VminsertAddr() + "," + vmstorage2.VminsertAddr(),
"-storageNode=" + vmstorage.VminsertAddr(),
})
vmselect := tc.MustStartVmselect("vmselect"+name, []string{
"-storageNode=" + vmstorage1.VmselectAddr() + "," + vmstorage2.VmselectAddr(),
"-storageNode=" + vmstorage.VmselectAddr(),
"-search.maxStalenessInterval=1m",
})
return &apptest.Vmcluster{
Vmstorages: []*apptest.Vmstorage{vmstorage1, vmstorage2},
Vmstorages: []*apptest.Vmstorage{vmstorage},
Vminsert: vminsert,
Vmselect: vmselect,
}

View File

@@ -59,7 +59,7 @@ func (ms *PrometheusMockStorage) Read(_ context.Context, query *prompb.Query, so
}
if !notMatch {
q.Timeseries = append(q.Timeseries, &prompb.TimeSeries{Labels: s.Labels, Samples: s.Samples})
q.Timeseries = append(q.Timeseries, &prompb.TimeSeries{Labels: s.Labels, Samples: s.Samples, Histograms: s.Histograms})
}
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/gogo/protobuf/proto"
"github.com/golang/snappy"
"github.com/prometheus/prometheus/model/histogram"
"github.com/prometheus/prometheus/model/labels"
"github.com/prometheus/prometheus/prompb"
"github.com/prometheus/prometheus/storage/remote"
@@ -86,10 +87,17 @@ func (rrs *RemoteReadServer) getReadHandler(t *testing.T) http.Handler {
samples = append(samples, sample)
}
}
var histograms []prompb.Histogram
for _, h := range s.Histograms {
if h.Timestamp >= startTs && h.Timestamp < endTs {
histograms = append(histograms, h)
}
}
var series prompb.TimeSeries
if len(samples) > 0 {
if len(samples) > 0 || len(histograms) > 0 {
series.Labels = s.Labels
series.Samples = samples
series.Histograms = histograms
}
ts[i] = &series
}
@@ -317,6 +325,37 @@ func generateRemoteReadSamples(idx int, startTime, endTime, numOfSamples int64)
return samples
}
// GenerateRemoteReadHistogramSeries generates a remote read series
// with native histogram samples within the given time range.
func GenerateRemoteReadHistogramSeries(start, end, numOfSamples int64) []*prompb.TimeSeries {
timeSeries := &prompb.TimeSeries{
Labels: []prompb.Label{
{Name: labels.MetricName, Value: "vm_histogram_metric"},
{Name: "job", Value: "0"},
},
}
delta := (end - start) / numOfSamples
mul := int64(0)
for t := start; t != end; t += delta {
mul++
h := &histogram.Histogram{
Schema: 0,
Count: uint64(10 * mul),
Sum: 25.5 * float64(mul),
ZeroThreshold: 0.001,
ZeroCount: uint64(2 * mul),
PositiveSpans: []histogram.Span{{Offset: 0, Length: 2}},
PositiveBuckets: []int64{mul, 2 * mul},
NegativeSpans: []histogram.Span{{Offset: 0, Length: 1}},
NegativeBuckets: []int64{4 * mul},
}
timeSeries.Histograms = append(timeSeries.Histograms, prompb.FromIntHistogram(t*1000, h))
}
return []*prompb.TimeSeries{timeSeries}
}
func labelsToLabelsProto(ls labels.Labels) []prompb.Label {
result := make([]prompb.Label, 0, ls.Len())
ls.Range(func(l labels.Label) {

View File

@@ -594,7 +594,6 @@ func TestSingleVMAgentMultitenancy(t *testing.T) {
fmt.Sprintf(`-remoteWrite.url=%s/api/v1/write`, remoteWriteSrv.URL),
"-remoteWrite.tmpDataPath=" + tc.Dir() + "/vmagent-multitenancy",
"-enableMultitenantHandlers",
"-enableMultitenancyViaHeaders",
})
vmagent.APIV1ImportPrometheus(t, []string{

View File

@@ -75,6 +75,118 @@ func TestClusterVmctlRemoteReadProtocol(t *testing.T) {
testRemoteReadProtocol(tc, clusterDst, newRemoteReadServer, vmctlFlags)
}
func TestSingleVmctlRemoteReadNativeHistograms(t *testing.T) {
fs.MustRemoveDir(t.Name())
tc := apptest.NewTestCase(t)
defer tc.Stop()
vmsingleDst := tc.MustStartDefaultVmsingle()
vmAddr := fmt.Sprintf("http://%s/", vmsingleDst.HTTPAddr())
vmctlFlags := []string{
`remote-read`,
`--remote-read-filter-time-start=2025-06-11T15:31:10Z`,
`--remote-read-filter-time-end=2025-06-11T15:31:20Z`,
`--remote-read-step-interval=minute`,
`--vm-addr=` + vmAddr,
`--disable-progress-bar=true`,
}
testRemoteReadNativeHistograms(tc, vmsingleDst, NewRemoteReadServer, vmctlFlags)
}
func TestSingleVmctlRemoteReadStreamNativeHistograms(t *testing.T) {
fs.MustRemoveDir(t.Name())
tc := apptest.NewTestCase(t)
defer tc.Stop()
vmsingleDst := tc.MustStartDefaultVmsingle()
vmAddr := fmt.Sprintf("http://%s/", vmsingleDst.HTTPAddr())
vmctlFlags := []string{
`remote-read`,
`--remote-read-filter-time-start=2025-06-11T15:31:10Z`,
`--remote-read-filter-time-end=2025-06-11T15:31:20Z`,
`--remote-read-step-interval=minute`,
`--vm-addr=` + vmAddr,
`--remote-read-use-stream=true`,
`--disable-progress-bar=true`,
}
testRemoteReadNativeHistograms(tc, vmsingleDst, NewRemoteReadStreamServer, vmctlFlags)
}
// testRemoteReadNativeHistograms verifies that native histograms are migrated
// as _count, _sum and _bucket series with vmrange labels in the same way
// as VictoriaMetrics converts native histograms received via Prometheus remote write protocol.
func testRemoteReadNativeHistograms(tc *apptest.TestCase, sut apptest.PrometheusWriteQuerier, newRemoteReadServer func(t *testing.T, series []*prompb.TimeSeries) *RemoteReadServer, vmctlFlags []string) {
t := tc.T()
t.Helper()
series := GenerateRemoteReadHistogramSeries(1749655870, 1749655880, 2)
rrs := newRemoteReadServer(t, series)
defer rrs.Close()
vmctlFlags = append(vmctlFlags, `--remote-read-src-addr=`+rrs.HTTPAddr())
tc.MustStartVmctl("vmctl", vmctlFlags)
sut.ForceFlush(t)
tc.Assert(&apptest.AssertOptions{
Retries: 300,
Msg: `unexpected native histogram metrics stored on vmsingle via the prometheus protocol`,
Got: func() any {
got := sut.PrometheusAPIV1Export(t, `{__name__=~".*"}`, apptest.QueryOpts{
Start: "2025-06-11T15:31:10Z",
End: "2025-06-11T15:32:20Z",
})
got.Sort()
return got.Data.Result
},
Want: expectedNativeHistogramQueryResult(),
CmpOpts: []cmp.Option{
cmpopts.IgnoreFields(apptest.PrometheusAPIV1QueryResponse{}, "Status", "Data.ResultType"),
},
})
}
// expectedNativeHistogramQueryResult returns the series expected to be stored in VictoriaMetrics
// after migrating the series generated by GenerateRemoteReadHistogramSeries(1749655870, 1749655880, 2).
func expectedNativeHistogramQueryResult() []*apptest.QueryResult {
metric := func(name, vmrange string) map[string]string {
m := map[string]string{
"__name__": name,
"job": "0",
}
if vmrange != "" {
m["vmrange"] = vmrange
}
return m
}
samples := func(v1, v2 float64) []*apptest.Sample {
return []*apptest.Sample{
{Timestamp: 1749655870000, Value: v1},
{Timestamp: 1749655875000, Value: v2},
}
}
resp := &apptest.PrometheusAPIV1QueryResponse{
Data: &apptest.QueryData{
Result: []*apptest.QueryResult{
{Metric: metric("vm_histogram_metric_count", ""), Samples: samples(10, 20)},
{Metric: metric("vm_histogram_metric_sum", ""), Samples: samples(25.5, 51)},
{Metric: metric("vm_histogram_metric_bucket", "-1.000e+00...-5.000e-01"), Samples: samples(4, 8)},
{Metric: metric("vm_histogram_metric_bucket", "-1.000e-03...1.000e-03"), Samples: samples(2, 4)},
{Metric: metric("vm_histogram_metric_bucket", "5.000e-01...1.000e+00"), Samples: samples(1, 2)},
{Metric: metric("vm_histogram_metric_bucket", "1.000e+00...2.000e+00"), Samples: samples(3, 6)},
},
},
}
// sort in the same way as the exported result
resp.Sort()
return resp.Data.Result
}
func testRemoteReadProtocol(tc *apptest.TestCase, sut apptest.PrometheusWriteQuerier, newRemoteReadServer func(t *testing.T) *RemoteReadServer, vmctlFlags []string) {
t := tc.T()
t.Helper()

View File

@@ -7,7 +7,7 @@ ROOT_IMAGE ?= alpine:3.24.1
ROOT_IMAGE_SCRATCH ?= scratch
CERTS_IMAGE := alpine:3.24.1
GO_BUILDER_IMAGE := golang:1.26.5
GO_BUILDER_IMAGE := golang:1.26.6
BUILDER_IMAGE := local/builder:2.0.0-$(shell echo $(GO_BUILDER_IMAGE) | tr :/ __)-1
BASE_IMAGE := local/base:1.1.4-$(shell echo $(ROOT_IMAGE) | tr :/ __)-$(shell echo $(CERTS_IMAGE) | tr :/ __)

View File

@@ -3,7 +3,7 @@ services:
# It scrapes targets defined in --promscrape.config
# And forward them to --remoteWrite.url
vmagent:
image: victoriametrics/vmagent:v1.148.0
image: victoriametrics/vmagent:v1.149.0
depends_on:
- "vmauth"
ports:
@@ -42,14 +42,14 @@ services:
# vmstorage shards. Each shard receives 1/N of all metrics sent to vminserts,
# where N is number of vmstorages (2 in this case).
vmstorage-1:
image: victoriametrics/vmstorage:v1.148.0-cluster
image: victoriametrics/vmstorage:v1.149.0-cluster
volumes:
- strgdata-1:/storage
command:
- "--storageDataPath=/storage"
restart: always
vmstorage-2:
image: victoriametrics/vmstorage:v1.148.0-cluster
image: victoriametrics/vmstorage:v1.149.0-cluster
volumes:
- strgdata-2:/storage
command:
@@ -59,7 +59,7 @@ services:
# vminsert is ingestion frontend. It receives metrics pushed by vmagent,
# pre-process them and distributes across configured vmstorage shards.
vminsert-1:
image: victoriametrics/vminsert:v1.148.0-cluster
image: victoriametrics/vminsert:v1.149.0-cluster
depends_on:
- "vmstorage-1"
- "vmstorage-2"
@@ -68,7 +68,7 @@ services:
- "--storageNode=vmstorage-2:8400"
restart: always
vminsert-2:
image: victoriametrics/vminsert:v1.148.0-cluster
image: victoriametrics/vminsert:v1.149.0-cluster
depends_on:
- "vmstorage-1"
- "vmstorage-2"
@@ -80,7 +80,7 @@ services:
# vmselect is a query fronted. It serves read queries in MetricsQL or PromQL.
# vmselect collects results from configured `--storageNode` shards.
vmselect-1:
image: victoriametrics/vmselect:v1.148.0-cluster
image: victoriametrics/vmselect:v1.149.0-cluster
depends_on:
- "vmstorage-1"
- "vmstorage-2"
@@ -90,7 +90,7 @@ services:
- "--vmalert.proxyURL=http://vmalert:8880"
restart: always
vmselect-2:
image: victoriametrics/vmselect:v1.148.0-cluster
image: victoriametrics/vmselect:v1.149.0-cluster
depends_on:
- "vmstorage-1"
- "vmstorage-2"
@@ -105,7 +105,7 @@ services:
# read requests from Grafana, vmui, vmalert among vmselects.
# It can be used as an authentication proxy.
vmauth:
image: victoriametrics/vmauth:v1.148.0
image: victoriametrics/vmauth:v1.149.0
depends_on:
- "vmselect-1"
- "vmselect-2"
@@ -119,7 +119,7 @@ services:
# vmalert executes alerting and recording rules
vmalert:
image: victoriametrics/vmalert:v1.148.0
image: victoriametrics/vmalert:v1.149.0
depends_on:
- "vmauth"
ports:

View File

@@ -3,7 +3,7 @@ services:
# It scrapes targets defined in --promscrape.config
# And forward them to --remoteWrite.url
vmagent:
image: victoriametrics/vmagent:v1.148.0
image: victoriametrics/vmagent:v1.149.0
depends_on:
- "victoriametrics"
ports:
@@ -18,7 +18,7 @@ services:
# VictoriaMetrics instance, a single process responsible for
# storing metrics and serve read requests.
victoriametrics:
image: victoriametrics/victoria-metrics:v1.148.0
image: victoriametrics/victoria-metrics:v1.149.0
ports:
- 8428:8428
- 8089:8089
@@ -59,7 +59,7 @@ services:
# vmalert executes alerting and recording rules
vmalert:
image: victoriametrics/vmalert:v1.148.0
image: victoriametrics/vmalert:v1.149.0
depends_on:
- "victoriametrics"
- "alertmanager"

View File

@@ -1,6 +1,6 @@
services:
vmagent:
image: victoriametrics/vmagent:v1.148.0
image: victoriametrics/vmagent:v1.149.0
depends_on:
- "victoriametrics"
ports:
@@ -14,7 +14,7 @@ services:
restart: always
victoriametrics:
image: victoriametrics/victoria-metrics:v1.148.0
image: victoriametrics/victoria-metrics:v1.149.0
ports:
- 8428:8428
volumes:
@@ -40,7 +40,7 @@ services:
restart: always
vmalert:
image: victoriametrics/vmalert:v1.148.0
image: victoriametrics/vmalert:v1.149.0
depends_on:
- "victoriametrics"
ports:
@@ -59,7 +59,7 @@ services:
- '--external.alert.source=explore?orgId=1&left=["now-1h","now","VictoriaMetrics",{"expr": },{"mode":"Metrics"},{"ui":[true,true,true,"none"]}]'
restart: always
vmanomaly:
image: victoriametrics/vmanomaly:v1.30.0
image: victoriametrics/vmanomaly:v1.30.2
depends_on:
- "victoriametrics"
ports:

View File

@@ -1,7 +1,7 @@
schedulers:
periodic:
infer_every: "1m"
fit_every: "100w" # the online model keeps learning during inference
fit_every: "1000d" # bootstrap-only schedule; use a finite cadence if accumulated state must be reset
fit_window: "2w"
models:

View File

@@ -1,3 +1,11 @@
---
build:
list: never
publishResources: false
render: never
sitemap:
disable: true
---
VictoriaMetrics Observability Stack integrates with AI assistants through [MCP servers](https://docs.victoriametrics.com/ai-tools/#mcp-servers)
and [agent skills](https://docs.victoriametrics.com/ai-tools/#agent-skills).
The integrations allow AI agents and automation tools to query Metrics, Logs, and Traces, analyze telemetry data,

View File

@@ -16,6 +16,44 @@ Please find the changelog for VictoriaMetrics Anomaly Detection below.
{{% collapse name="2026" open=true %}}
## v1.30.2
Released: 2026-08-13
- UI: Updated [vmanomaly UI](https://docs.victoriametrics.com/anomaly-detection/ui/) from [v1.8.1](https://docs.victoriametrics.com/anomaly-detection/ui/#v181) to [v1.8.2](https://docs.victoriametrics.com/anomaly-detection/ui/#v182), fixing tenant discovery and switching for multitenant VictoriaMetrics datasources.
- FEATURE: Added **query**-level [`data_range`, `detection_direction`, `min_dev_from_expected`, and `min_rel_dev_from_expected`](https://docs.victoriametrics.com/anomaly-detection/components/reader/#per-query-parameters). Model-level placement is deprecated but remains a compatible fallback.
- IMPROVEMENT: Added [`reader.workers`](https://docs.victoriametrics.com/anomaly-detection/components/reader/#config-parameters) to cap concurrent datasource requests and disk-streamed query chunks; `0` selects an automatic bound.
- IMPROVEMENT: Added [`settings.native_threads_per_worker`](https://docs.victoriametrics.com/anomaly-detection/components/settings/#parallelization) to reduce [native-thread oversubscription](https://scikit-learn.org/stable/computing/parallelism.html#oversubscription-spawning-too-many-threads), throttling risk, fit latency, and memory. For example, with 16 CPUs/workers, [Temporal Envelope](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope) fit time fell 70.6% for 1,000 univariate models and 11.5% for 100 x 10-channel grouped models; inference was unchanged.
- IMPROVEMENT: Removed temporary fit-data generations after all dependent models finish and commit, while safely retaining failed or overlapping generations.
- IMPROVEMENT: Reduced disk-backed grouped multivariate memory and fit latency without model or state migration. For example, 100 x 100-channel four-week fits cut peak PSS/fit time by 63%/56% for [Temporal Envelope](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope).
- BUGFIX: Made [multivariate models](https://docs.victoriametrics.com/anomaly-detection/components/models/#multivariate-models) independent of input channel order when the fitted channel set matches; missing, extra, or duplicate channels remain rejected.
## v1.30.1
Released: 2026-08-06
- UI: Updated [vmanomaly UI](https://docs.victoriametrics.com/anomaly-detection/ui/) from [v1.8.0](https://docs.victoriametrics.com/anomaly-detection/ui/#v180) to [v1.8.1](https://docs.victoriametrics.com/anomaly-detection/ui/#v181). The update improves UX validation and fixes regressions introduced by new design.
- IMPROVEMENT: Reduced fit and inference latency for the Z-score, MAD, standard deviation, Seasonal Quantile, and Rolling Quantile online models. Representative service-stage gains range from 1.5-2.6x for fit and 1.7-2.3x for inference, depending on model, storage mode, and data size.
- IMPROVEMENT: Removed forwarded datasource credentials from in-memory state for completed, failed, canceled, and shutting-down [analysis and autotune tasks](https://docs.victoriametrics.com/anomaly-detection/components/server/#time-series-analysis-and-autotune-api).
- BUGFIX: Stabilized [Temporal Envelope](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope) after fitting across a late level shift. Its level, trend, residual, and supported calendar state now initialize coherently from the recent regime, avoiding stale fitted magnitudes and false seasonal oscillations when periodic inference starts.
- BUGFIX: Corrected `/api/v1/timeseries/characteristics` seasonality detection for time series whose timestamps are offset from whole sampling intervals. Trend interpolation now preserves the original observation grid, allowing daily and weekly patterns to be detected on shifted grids.
- BUGFIX: Restored backward-compatible `inference_only` [backtesting](https://docs.victoriametrics.com/anomaly-detection/components/scheduler/#backtesting-scheduler) for configurations that omit `infer_every`. The scheduler derives its inference grid from the query step or reader sampling period and preserves valid single-timestamp range queries.
- BUGFIX: Aligned periodic inference for exact-capable online models with exact backtesting (used in [UI](https://docs.victoriametrics.com/anomaly-detection/ui/) experiments) by applying the configured `infer_every` as the causal update cadence.
- BUGFIX: Corrected [self-monitoring](https://docs.victoriametrics.com/anomaly-detection/components/monitoring/#writer-behaviour-metrics) accounting so failed VictoriaMetrics write attempts contribute to `vmanomaly_writer_request_duration_seconds`, including connection retries, and inference counts only unseen *valid* rows in `vmanomaly_model_datapoints_accepted`.
- BUGFIX: Fixed service-level [`settings.anomaly_score_outside_data_range`](https://docs.victoriametrics.com/anomaly-detection/components/settings/#anomaly-score-outside-data-range) propagation so its configured score applies to every model unless the model defines its own override.
## v1.30.0
Released: 2026-07-23

View File

@@ -24,7 +24,7 @@ The decision to set the changepoint at `1.0` is made to ensure consistency acros
> `anomaly_score` is a metric itself, which preserves all labels found in input data and (optionally) appends [custom labels, specified in writer](https://docs.victoriametrics.com/anomaly-detection/components/writer/#metrics-formatting) - follow the link for detailed output example.
## How is anomaly score calculated?
For most of the [univariate models](https://docs.victoriametrics.com/anomaly-detection/components/models/#univariate-models) that can generate `yhat`, `yhat_lower`, and `yhat_upper` time series in [their output](https://docs.victoriametrics.com/anomaly-detection/components/models/#vmanomaly-output) (such as [Prophet](https://docs.victoriametrics.com/anomaly-detection/components/models/#prophet) or [Z-score](https://docs.victoriametrics.com/anomaly-detection/components/models/#z-score)), the anomaly score is calculated as follows:
For most of the [univariate models](https://docs.victoriametrics.com/anomaly-detection/components/models/#univariate-models) that can generate `yhat`, `yhat_lower`, and `yhat_upper` time series in [their output](https://docs.victoriametrics.com/anomaly-detection/components/models/#vmanomaly-output) (such as [Temporal Envelope](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope) or [Online Z-score](https://docs.victoriametrics.com/anomaly-detection/components/models/#online-z-score)), the anomaly score is calculated as follows:
- If `yhat` (expected series behavior) equals `y` (actual value observed), then the anomaly score is 0.
- If `y` (actual value observed) falls within the `[yhat_lower, yhat_upper]` confidence interval, the anomaly score will gradually approach 1, the closer `y` is to the boundary.
- If `y` (actual value observed) strictly exceeds the `[yhat_lower, yhat_upper]` interval, the anomaly score will be greater than 1, increasing as the margin between the actual value and the expected range grows.
@@ -33,7 +33,7 @@ Please see example graph illustrating this logic below:
![anomaly-score-calculation-example](vmanomaly-prophet-example.webp)
> p.s. please note that additional post-processing logic might be applied to produced anomaly scores, if common arguments like [`min_dev_from_expected`](https://docs.victoriametrics.com/anomaly-detection/components/models/#minimal-deviation-from-expected) or [`detection_direction`](https://docs.victoriametrics.com/anomaly-detection/components/models/#detection-direction) are enabled for a particular model. Follow the links above for the explanations.
> Additional post-processing logic may be applied to produced anomaly scores when query policies such as [`min_dev_from_expected`](https://docs.victoriametrics.com/anomaly-detection/components/models/#minimal-deviation-from-expected) or [`detection_direction`](https://docs.victoriametrics.com/anomaly-detection/components/models/#detection-direction) are configured. Follow the links for details.
## How does vmanomaly work?
@@ -82,7 +82,7 @@ reader:
`vmanomaly` supports timezone-aware anomaly detection {{% available_from "v1.18.0" anomaly %}} through a `tz` argument, available both at the [reader level](https://docs.victoriametrics.com/anomaly-detection/components/reader/#vm-reader) and at the [query level](https://docs.victoriametrics.com/anomaly-detection/components/reader/#per-query-parameters).
For models that depend on seasonality, such as [`ProphetModel`](https://docs.victoriametrics.com/anomaly-detection/components/models/#prophet) and [`OnlineQuantileModel`](https://docs.victoriametrics.com/anomaly-detection/components/models/#online-seasonal-quantile), handling timezone shifts is crucial. Changes like Daylight Saving Time (DST) can disrupt seasonality patterns learned by models, resulting in inaccurate anomaly predictions as the periodic patterns shift with time. Proper timezone configuration ensures that seasonal cycles align with expected intervals, even as DST changes occur.
For models that depend on seasonality, such as [`TemporalEnvelopeModel`](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope) and [`OnlineQuantileModel`](https://docs.victoriametrics.com/anomaly-detection/components/models/#online-seasonal-quantile), handling timezone shifts is crucial. Changes like Daylight Saving Time (DST) can disrupt seasonality patterns learned by models, resulting in inaccurate anomaly predictions as the periodic patterns shift with time. Proper timezone configuration ensures that seasonal cycles align with expected intervals, even as DST changes occur.
To enable timezone handling:
1. **Reader-level**: Set `tz` in the [`reader`](https://docs.victoriametrics.com/anomaly-detection/components/reader/#vm-reader) section to a specific timezone (e.g., `Europe/Berlin`) to apply this setting to all queries.
@@ -100,9 +100,9 @@ reader:
tz: 'Europe/London' # per-query override
models:
seasonal_model:
class: 'prophet'
class: 'temporal_envelope'
queries: ['your_query']
# other model params ...
seasonalities: ['hod_smooth', 'dow_smooth']
```
## Output produced by vmanomaly
@@ -124,9 +124,8 @@ Selecting the best model for `vmanomaly` depends on the data's nature and the [t
- Use [Online MAD](https://docs.victoriametrics.com/anomaly-detection/components/models/#online-mad) for simple, mostly stationary data with no-to-slow trend, when robustness to outliers is important.
- Use [Online Z-score](https://docs.victoriametrics.com/anomaly-detection/components/models/#online-z-score) for simple, light-tailed data where standard-deviation units are meaningful.
- Use [Temporal Envelope](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope) {{% available_from "v1.30.0" anomaly %}} for complex data with trends, calendar patterns, holidays, or persistent shifts. It is the preferred *online* alternative to Prophet (which will be deprecated in the future releases).
- Use [Temporal Envelope](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope) {{% available_from "v1.30.0" anomaly %}} for complex data with trends, calendar patterns, holidays, or persistent shifts. It is the preferred online migration target for existing Prophet configurations.
- Use multivariate Temporal Envelope when normal relationships between aligned metrics matter. This should replace [Isolation Forest](https://docs.victoriametrics.com/anomaly-detection/components/models/#isolation-forest-multivariate) used in previous versions of `vmanomaly`, which will be deprecated in future releases.
- Use [Prophet](https://docs.victoriametrics.com/anomaly-detection/components/models/#prophet) when Prophet-specific decomposition outputs, or offline batch behavior are required. Consider using Temporal Envelope instead, as it is more efficient and provides better results in most cases.
There is also an option to auto-tune the most important parameters of a selected model class {{% available_from "v1.12.0" anomaly %}}. {{% available_from "v1.30.0" anomaly %}} The asynchronous autotune API can first profile a bounded sample through `/api/v1/timeseries/characteristics`, then tune a shared concrete configuration through `/api/v1/autotune/tasks`. See the [autotune workflow](https://docs.victoriametrics.com/anomaly-detection/components/models/#shared-asynchronous-autotune-workflow).
@@ -136,7 +135,7 @@ Still not 100% sure what to use? We are [here to help](https://docs.victoriametr
## Incorporating domain knowledge
Anomaly detection models can significantly improve when incorporating business-specific assumptions about the data and what constitutes an anomaly. `vmanomaly` supports various [business-side configuration parameters](https://docs.victoriametrics.com/anomaly-detection/components/models/#common-args) across all built-in models to **reduce [false positives](https://victoriametrics.com/blog/victoriametrics-anomaly-detection-handbook-chapter-1/#false-positive)** and **align model behavior with business needs**, for example:
Anomaly detection models can significantly improve when incorporating business-specific assumptions about the data and what constitutes an anomaly. `vmanomaly` supports [business policies](https://docs.victoriametrics.com/anomaly-detection/components/models/#common-args) across built-in models to **reduce [false positives](https://victoriametrics.com/blog/victoriametrics-anomaly-detection-handbook-chapter-1/#false-positive)** and **align model behavior with business needs**, for example:
- **Setting `detection_direction`** - use [`detection_direction`](https://docs.victoriametrics.com/anomaly-detection/components/models/#detection-direction) to specify whether anomalies occur **above or below expectations**:
- Set to `above_expected` for metrics like error rates, where spikes indicate anomalies.
@@ -164,7 +163,7 @@ Then, the following config may be used to benefit from incorporating domain know
schedulers:
periodic_http:
class: periodic
fit_every: 12w
fit_every: 1000d
fit_window: 1w
infer_every: 1m
# other schedulers ...
@@ -173,18 +172,19 @@ reader:
queries:
percentage_4xx:
expr: respective_metricsQL_expr
data_range: [0, 0.05] # to automatically trigger anomaly score > 1 for error rates > 5%
data_range: [0, 0.05] # query-level business policy from v1.30.2; error rates >5% trigger anomaly score >1
detection_direction: 'above_expected' # query-level from v1.30.2; only spikes are anomalous
min_dev_from_expected: [0, 0.005] # query-level from v1.30.2; ignore upward deviations below 0.5%
min_rel_dev_from_expected: [0, 10] # query-level from v1.30.2; ignore upward deviations below 10%
step: 1m
models:
# other models ...
zscore: # let it be online Z-score, for simplicity
class: zscore_online # online model update itself each infer call, resulting in resource-efficient setups
z_threshold: 3.0
decay: 0.99 # give more weight to recent data while using the bootstrap-only fit schedule
schedulers: ['periodic_http']
queries: ['percentage_4xx']
detection_direction: 'above_expected' # as interested only in spikes, drops are OK
min_dev_from_expected: [0, 0.005] # <0.5% deviations vs expected values should be neglected, generating anomaly score == 0
min_rel_dev_from_expected: [0, 0.1] # <10% relative deviations vs expected values should be neglected, generating anomaly score == 0
# to align predictions to be within [0, 5%] interval, defined in reader.queries.percentage_4xx.data_range
clip_predictions: True
# specify output series produced by vmanomaly to be written to VictoriaMetrics in `writer`
@@ -230,7 +230,7 @@ models:
schedulers: ['scheduler_alias'] # if omitted, all the defined schedulers will be attached
queries: ['query_alias1'] # if omitted, all the defined queries will be attached
# https://docs.victoriametrics.com/anomaly-detection/components/models/#provide-series
provide_series: ['anomaly_score']
provide_series: ['anomaly_score']
# ... other models
reader:
@@ -254,8 +254,9 @@ Configuration above will produce N intervals of full length (`fit_window`=14d +
## Forecasting
`vmanomaly` can generate future forecasts using [Temporal Envelope](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope) {{% available_from "v1.30.0" anomaly %}} or [ProphetModel](https://docs.victoriametrics.com/anomaly-detection/components/models/#prophet) {{% available_from "v1.25.3" anomaly %}}. This is helpful for capacity planning, resource allocation, or trend analysis when the underlying data is complex and exceeds what inline MetricsQL queries, including [predict_linear](https://docs.victoriametrics.com/victoriametrics/metricsql/#predict_linear), can handle.
`vmanomaly` can generate future forecasts with [Temporal Envelope](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope) {{% available_from "v1.30.0" anomaly %}}, the preferred online forecasting model. [ProphetModel](https://docs.victoriametrics.com/anomaly-detection/components/models/#prophet) {{% available_from "v1.25.3" anomaly %}} also supports forecasting for existing offline configurations. Forecasts help with capacity planning, resource allocation, or trend analysis when the underlying data is complex and exceeds what inline MetricsQL queries, including [predict_linear](https://docs.victoriametrics.com/victoriametrics/metricsql/#predict_linear), can handle.
> [!WARNING]
> However, please note that this mode should be used with care, as the model will produce `yhat_{h}` (and probably `yhat_lower_{h}`, and `yhat_upper_{h}`) time series **for each timeseries returned by input queries and for each forecasting horizon specified in `forecast_at` argument, which can lead to a significant increase in the number of active timeseries in VictoriaMetrics TSDB**.
Here's an example of how to produce forecasts using `vmanomaly` and combine it with the regular model, e.g. to estimate daily outcomes for a disk usage metric:
@@ -265,12 +266,12 @@ Here's an example of how to produce forecasts using `vmanomaly` and combine it w
schedulers:
periodic_5m: # this scheduler will be used to produce anomaly scores each 5 minutes using "regular" simple model
class: 'periodic'
fit_every: '100w'
fit_every: '1000d'
fit_window: '3d'
infer_every: '5m'
periodic_forecast: # this scheduler will be used to produce forecasts each 24h using "daily" model
class: 'periodic'
fit_every: '1000w'
fit_every: '1000d'
fit_window: '730d' # to fit the model on 2 years of data to account for seasonality and holidays
infer_every: '24h'
# https://docs.victoriametrics.com/anomaly-detection/components/reader/#vm-reader
@@ -290,6 +291,7 @@ reader:
1h
)
data_range: [0, 1]
detection_direction: 'above_expected' # query-level from v1.30.2
# step: '1m' # default will be inherited from sampling_period
disk_usage_perc_1d:
expr: |
@@ -301,14 +303,15 @@ reader:
)
step: '1d' # override default step to 1d, as we want to produce daily forecasts
data_range: [0, 1]
detection_direction: 'above_expected' # query-level from v1.30.2
# https://docs.victoriametrics.com/anomaly-detection/components/models/
models:
quantile_5m:
class: 'quantile_online' # online model, which updates itself each infer call
queries: ['disk_usage_perc_5m']
schedulers: ['periodic_5m']
decay: 0.99 # give more weight to recent data while using the bootstrap-only fit schedule
clip_predictions: True
detection_direction: 'above_expected' # as we are interested in spikes in capacity planning
quantiles: [0.25, 0.5, 0.75] # to produce median and upper quartiles
iqr_threshold: 2.0
@@ -316,8 +319,9 @@ models:
class: 'temporal_envelope'
queries: ['disk_usage_perc_1d']
schedulers: ['periodic_forecast']
alpha: 0.005 # capture the changes faster if increased
loss_reactivity: 3 # allow new deviations to update the envelope
clip_predictions: True
detection_direction: 'above_expected' # as we are interested in spikes in capacity planning
forecast_at: ['3d', '7d'] # this will produce forecasts for 3 and 7 days ahead
provide_series: ['yhat', 'yhat_upper'] # to write forecasts back to VictoriaMetrics, omitting `yhat_lower` as it is not needed in this example
seasonalities: [dow_smooth]
@@ -426,13 +430,15 @@ For information on migrating between different versions of `vmanomaly`, please r
> {{% available_from "v1.24.0" anomaly %}} This feature is best used in conjunction with [stateful mode](https://docs.victoriametrics.com/anomaly-detection/components/settings/#state-restoration) to ensure that the model state is preserved across service restarts.
> {{% available_from "v1.30.2" anomaly %}} Scheduler-managed fit data is **temporary**. It is removed after every dependent univariate or multivariate model completes fitting and commits its state, rather than being retained until the next `fit_every` cycle. Model dumps and state metadata remain available for restoration.
Here's an example of how to set it up in docker-compose using volumes:
```yaml
services:
# ...
vmanomaly:
container_name: vmanomaly
image: victoriametrics/vmanomaly:v1.30.0
image: victoriametrics/vmanomaly:v1.30.2
# ...
restart: always
volumes:
@@ -503,7 +509,7 @@ settings:
schedulers:
periodic:
class: 'periodic'
fit_every: '180d' # we need only initial fit to start
fit_every: '1000d'
fit_window: '4h' # reduced window, especially if the data doesn't have strong seasonality
infer_every: '1m' # the model will be updated during each infer call
# other schedulers ...
@@ -511,7 +517,7 @@ models:
zscore_example:
class: 'zscore_online'
min_n_samples_seen: 120 # i.e. minimal relevant seasonality or (initial) fit_window / sampling_period
decay: 0.999 # decay factor to control how fast the model adapts to new data, the lower, the faster it adapts
decay: 0.99 # decay factor to control how fast the model adapts to new data, the lower, the faster it adapts
schedulers: ['periodic']
# other model params ...
# other config sections ...
@@ -525,11 +531,11 @@ As a result, switching from the offline Z-score model to the Online Z-score mode
**New configuration**:
- `fit_window`: 4 hours
- `fit_every`: 180 days ( >1 week)
- `fit_every`: 1000 days ( >1 week)
The old configuration would perform 168 (hours in a week) `fit` calls, each using 2 days (48 hours) of data, totaling 168 * 48 = 8064 hours of data for each timeseries returned.
The new configuration performs only 1 `fit` call in 180 days, using 4 hours of data initially, totaling 4 hours of data, which is **magnitudes smaller**.
The new configuration performs only 1 `fit` call in 1000 days, using 4 hours of data initially, totaling 4 hours of data, which is **magnitudes smaller**.
P.s. `infer` data volume will remain the same for both models, so it does not affect the overall calculations.
@@ -554,11 +560,10 @@ reader:
expr: 'sum(ALERTS{alertstate=~'(pending|firing)'}) by (alertstate)'
max_points_per_query: 5000 # query-level override
models:
prophet:
temporal_envelope:
class: temporal_envelope
# other model args
queries: [
'sum_alerts',
]
queries: ['sum_alerts']
# other config sections
```
@@ -575,11 +580,10 @@ reader:
sum_alerts:
expr: 'sum(ALERTS{alertstate=~'(pending|firing)'}) by (alertstate)'
models:
prophet:
temporal_envelope:
class: temporal_envelope
# other model args
queries: [
'sum_alerts',
]
queries: ['sum_alerts']
# other config sections
```
@@ -594,12 +598,10 @@ reader:
sum_alerts_firing:
expr: 'sum(ALERTS{alertstate='firing'}) by ()'
models:
prophet:
temporal_envelope:
class: temporal_envelope
# other model args
queries: [
'sum_alerts_pending',
'sum_alerts_firing',
]
queries: ['sum_alerts_pending', 'sum_alerts_firing']
# other config sections
```
@@ -649,10 +651,12 @@ options:
Minimum level to log. Default: INFO
```
For a side-by-side comparison of all split modes and their resulting sub-configurations, see [splitting strategies](https://docs.victoriametrics.com/anomaly-detection/scaling-vmanomaly/#splitting-strategies).
Heres an example of using the config splitter to divide configurations based on the `extra_filters` argument from the reader section:
```sh
docker pull victoriametrics/vmanomaly:v1.30.0 && docker image tag victoriametrics/vmanomaly:v1.30.0 vmanomaly
docker pull victoriametrics/vmanomaly:v1.30.2 && docker image tag victoriametrics/vmanomaly:v1.30.2 vmanomaly
```
```sh
@@ -685,10 +689,11 @@ reader:
# ...
queries:
extra_big_query: metricsql_expression_returning_too_many_timeseries
extra_filters:
extra_filters: [
# suppose you have a label `region` with values to deterministically define such subsets
- '{env="region_name_1"}'
'{env="region_name_1"}',
# ...
]
```
```yaml
@@ -698,10 +703,11 @@ reader:
# ...
queries:
extra_big_query: metricsql_expression_returning_too_many_timeseries
extra_filters:
extra_filters: [
# suppose you have a label `region` with values to deterministically define such subsets
- '{region="region_name_2"}'
'{region="region_name_2"}',
# ...
]
```
## Monitoring vmanomaly

View File

@@ -45,7 +45,7 @@ There are 2 types of compatibility to consider when migrating in stateful mode:
| Group start | Group end | Compatibility | Notes |
|---------|--------- |------------|-------|
| [v1.29.1](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1291) | [v1.30.0](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1300) | Fully Compatible | v1.30.0 adds new [Temporal Envelope](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope) model state without changing the compatibility of existing model and data artifacts. |
| [v1.29.1](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1291) | [v1.30.2](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1302) | Fully Compatible | v1.30.0 adds new [Temporal Envelope](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope) model state without changing the compatibility of existing model and data artifacts. v1.30.2 remains compatible with v1.30.1 state and its compatible predecessors; no persisted-state migration is required. |
| [v1.28.7](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1287) | [v1.29.0](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1290) | Partially compatible* | Dumped models of class [prophet](https://docs.victoriametrics.com/anomaly-detection/components/models/#prophet) and [seasonal quantile](https://docs.victoriametrics.com/anomaly-detection/components/models/#online-seasonal-quantile) have problems with loading to [v1.29.0](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1290) due to dropped `pytz` library. **Upgrading directly from v1.28.7 to [v1.29.1](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1291) with a fix is suggested** |
| [v1.26.0](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1262) | [v1.28.7](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1287) | Fully Compatible | [v1.28.0](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1280) introduced [rolling](https://docs.victoriametrics.com/anomaly-detection/components/models/#rolling-models) model class drop in favor of [online](https://docs.victoriametrics.com/anomaly-detection/components/models/#online-models) models (`rolling_quantile` and `std` models), however, it does not impact compatibility, as artifacts were not produced by default for rolling models. Also, offline `mad` and `zscore` models are redirecting to their respective online counterparts since [v1.28.4](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1284). |
| [v1.25.3](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1253) | [v1.26.0](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1270) | Partially Compatible* | [v1.25.3](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1253) introduced `forecast_at` argument for base [univariate](https://docs.victoriametrics.com/anomaly-detection/components/models/#univariate-models) and `Prophet` [models](https://docs.victoriametrics.com/anomaly-detection/components/models/#prophet), however, itself remains backward-reversible from newer states like [v1.26.2](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1262), [v1.27.0](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1270). (All models except `isolation_forest_multivariate` class will be dropped) |
@@ -71,7 +71,7 @@ In stateless mode, the migration process is almost straightforward as there are
**Breaking Changes**
- [v1.12.0](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1120) **ARIMA** model is removed from [built-in models](https://docs.victoriametrics.com/anomaly-detection/components/models/#built-in-models); Action: replace ARIMA by [Prophet](https://docs.victoriametrics.com/anomaly-detection/components/models/#prophet) or alternative seasonal models in `model(s)` section of your configuration files.
- [v1.12.0](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1120) **ARIMA** model is removed from [built-in models](https://docs.victoriametrics.com/anomaly-detection/components/models/#built-in-models). Action: for vmanomaly v1.30.0 and newer, replace ARIMA with [Temporal Envelope](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope) {{% available_from "v1.30.0" anomaly %}}; for older releases, use another supported seasonal model in the `models` section of the configuration.
- [v1.9.0](https://docs.victoriametrics.com/anomaly-detection/changelog/#v190) The `sampling_period` parameter is now mandatory in `VmReader`. This change aims to clarify and standardize the frequency of input/output in `vmanomaly`, thereby reducing uncertainty and aligning with user expectations; Action: Add the `sampling_period` parameter to your `VmReader` configuration, e.g.:

View File

@@ -126,13 +126,18 @@ groups:
> docker pull quay.io/victoriametrics/vmanomaly:vX.Y.Z
> ```
> [!NOTE] ARM64 startup on affected Apple Silicon virtualization
> On some `linux/arm64` environments running through virtualization on Apple M4/M5 hosts, `vmanomaly` may exit with `SIGILL` (exit code `132`) before startup. This is caused by the virtualized host advertising an SVE2 capability that traps when used by OpenSSL 4.x; it does not affect all ARM64 systems.
>
> On affected hosts, add `-e OPENSSL_armcap=0` to `docker run`, or add `- OPENSSL_armcap=0` under the service's Docker Compose `environment`, matching the list syntax used below. This disables ARM cryptographic acceleration, so apply it only as a temporary workaround on affected hosts.
Below are the steps to get `vmanomaly` up and running inside a Docker container:
1. Pull Docker image:
```sh
docker pull victoriametrics/vmanomaly:v1.30.0
docker pull victoriametrics/vmanomaly:v1.30.2
```
2. Create the license file with your license key.
@@ -152,7 +157,7 @@ docker run -it \
-v ./license:/license \
-v ./config.yaml:/config.yaml \
-p 8490:8490 \
victoriametrics/vmanomaly:v1.30.0 \
victoriametrics/vmanomaly:v1.30.2 \
/config.yaml \
--licenseFile=/license \
--loggerLevel=INFO \
@@ -169,7 +174,7 @@ docker run -it \
-e VMANOMALY_DATA_DUMPS_DIR=/tmp/vmanomaly/data \
-e VMANOMALY_MODEL_DUMPS_DIR=/tmp/vmanomaly/models \
-p 8490:8490 \
victoriametrics/vmanomaly:v1.30.0 \
victoriametrics/vmanomaly:v1.30.2 \
/config.yaml \
--licenseFile=/license \
--loggerLevel=INFO \
@@ -182,7 +187,7 @@ services:
# ...
vmanomaly:
container_name: vmanomaly
image: victoriametrics/vmanomaly:v1.30.0
image: victoriametrics/vmanomaly:v1.30.2
# ...
restart: always
volumes:
@@ -245,12 +250,13 @@ Before deploying, check the correctness of your configuration validate config fi
### Example
Here is an example of a config file that runs the online [Temporal Envelope](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope) model on a CPU metric. The scheduler runs inference every five minutes and performs a full refit only every 100 weeks; between refits the model updates causally from each inference batch. The initial fit uses four weeks of data. The model produces `anomaly_score`, `yhat`, `yhat_lower`, and `yhat_upper` [series](https://docs.victoriametrics.com/anomaly-detection/components/models/#vmanomaly-output) for debugging, and its hour-of-day and day-of-week profiles follow the query timezone and daylight-saving-time changes.
Here is an example of a config file that runs the online [Temporal Envelope](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope) model on a CPU metric. The scheduler runs inference every five minutes and uses the fit only for initial bootstrap; between fits the model updates causally from each inference batch. The initial fit uses four weeks of data. The model produces `anomaly_score`, `yhat`, `yhat_lower`, and `yhat_upper` [series](https://docs.victoriametrics.com/anomaly-detection/components/models/#vmanomaly-output) for debugging, and its hour-of-day and day-of-week profiles follow the query timezone and daylight-saving-time changes.
```yaml
settings:
# https://docs.victoriametrics.com/anomaly-detection/components/settings/
n_workers: 2 # number of workers to run workload in parallel, set to 0 or negative number to use all available CPU cores
native_threads_per_worker: 0 # automatically divide container-aware CPU capacity across workers
anomaly_score_outside_data_range: 5.0 # default anomaly score for anomalies outside expected data range
restore_state: true # restore state from previous run, available since v1.24.0
# https://docs.victoriametrics.com/anomaly-detection/components/settings/#logger-levels
@@ -263,13 +269,12 @@ settings:
model.online.temporal_envelope: WARNING
schedulers:
100w_5m:
online_5m:
# https://docs.victoriametrics.com/anomaly-detection/components/scheduler/#periodic-scheduler
class: 'periodic'
infer_every: '5m'
scatter_infer_jobs: true
# Temporal Envelope learns online between full refits.
fit_every: '100w'
fit_every: '1000d'
fit_window: '4w'
models:
@@ -277,7 +282,7 @@ models:
temporal_envelope_model:
class: 'temporal_envelope'
queries: ['cpu_user']
schedulers: ['100w_5m']
schedulers: ['online_5m']
provide_series: ['anomaly_score', 'yhat', 'yhat_lower', 'yhat_upper'] # for debugging
seasonalities: ['hod_smooth', 'dow_smooth']
alpha: 0.005 # trend reactivity; try 0.0025-0.02
@@ -292,12 +297,15 @@ reader:
tenant_id: '0:0'
sampling_period: "5m"
tz: 'UTC' # set the IANA timezone that defines local calendar patterns, e.g. 'America/New_York'
workers: 0 # automatically choose bounded datasource concurrency
series_processing_batch_size: 8 # number of time series to process together while preparing data for fit or infer stages
queries:
# define your queries with MetricsQL - https://docs.victoriametrics.com/victoriametrics/metricsql/
cpu_user:
expr: 'sum(rate(node_cpu_seconds_total{mode=~"user"}[10m])) by (container)'
max_datapoints_per_query: 15000 # to deal with longer queries hitting search.MaxPointsPerTimeseries
data_range: [0, 'inf'] # query-level business policy from v1.30.2
detection_direction: 'above_expected' # query-level from v1.30.2; only spikes are anomalous
max_points_per_query: 15000 # to deal with longer queries hitting search.maxPointsPerTimeseries
# other queries ...
writer:

View File

@@ -32,14 +32,15 @@ schedulers:
periodic_1d: # alias
class: 'periodic' # scheduler class
infer_every: "30s"
fit_every: "1h"
fit_every: "1000d"
fit_window: "24h"
# https://docs.victoriametrics.com/anomaly-detection/components/models/
models:
zscore: # we can set up alias for model
class: 'zscore' # model class
class: 'zscore_online' # online model class
z_threshold: 3.5
decay: 0.99 # give more weight to recent data while using the bootstrap-only fit schedule
queries: ['cpu_seconds_total', 'host_network_receive_errors']
# https://docs.victoriametrics.com/anomaly-detection/components/reader/#vm-reader
@@ -80,6 +81,7 @@ Additionally, a replication factor `R ≥ 1` ensures [high availability](#high-a
{{% content "vmanomaly-sharding-ha-diagram.md" %}}
> [!WARNING]
> Please [refer to deployment options section](#deployment-options) for the examples (Docker, Docker Compose, Helm). To avoid duplicate metrics being reported from each vmanomaly service used in sharded mode, make sure that [deduplication](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#deduplication) is configured on vmsingle or vmselect and vmstorage for the VictoriaMetrics instance used in the [writer section of the configuration](https://docs.victoriametrics.com/anomaly-detection/components/writer/).
Sharding configuration can be controlled by using the following environment variables:
@@ -87,7 +89,94 @@ Sharding configuration can be controlled by using the following environment vari
- **`VMANOMALY_MEMBERS_COUNT`**: Defines the total number of shards (i.e., available nodes to distribute [sub-configurations](#sub-configuration) to). <br>Defaults to `1` for backward compatibility.
- **`VMANOMALY_MEMBER_NUM`**: Specifies the shard index (`0` to `VMANOMALY_MEMBERS_COUNT - 1`), determining the subset of [sub-configurations](#sub-configuration) to run on a specific node. Defaults to `0`. Supports automatic **pod name discovery** in Kubernetes [StatefulSets](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/) (e.g., if set to `vmanomaly-node-exporter-7`, shard `7` will be extracted).
- **`VMANOMALY_REPLICATION_FACTOR`**: If `R > 1`, enables [high availability](#high-availability) by ensuring each [sub-configuration](#sub-configuration) is assigned to exactly `R` shards. Defaults to `1` (no replication).
- **`VMANOMALY_SPLIT_BY`**: Defines the logical entity used to split the global config into [sub-configurations](#sub-configuration). Defaults to `complete`, which provides the most granular distribution (1 model per [sub-config](#sub-configuration), mapped to 1 query and attached to 1 scheduler) for balanced workloads.
- **`VMANOMALY_SPLIT_BY`**: Defines the logical entity used to split the global config into [sub-configurations](#sub-configuration). The accepted values are `SCHEDULERS`, `MODELS`, `QUERIES`, `EXTRA_FILTERS`, and `COMPLETE` (case-insensitive). It defaults to `COMPLETE`, which usually provides the most granular and balanced distribution.
The split strategies differ as follows:
| `VMANOMALY_SPLIT_BY` | Unit of work in each sub-configuration | Recommended use |
| --- | --- | --- |
| `SCHEDULERS` | One scheduler and the workload attached to it | Separate workloads by fit and inference cadence. The number of sub-configurations is limited by the number of referenced schedulers. |
| `MODELS` | One configured model alias with its attached schedulers and queries | Isolate computationally different models or distribute several models that process the same queries. |
| `QUERIES` | One query for [univariate models](https://docs.victoriametrics.com/anomaly-detection/components/models/#univariate-models); the complete attached query set for each [multivariate model](https://docs.victoriametrics.com/anomaly-detection/components/models/#multivariate-models) | Distribute independent query workloads. Queries belonging to one multivariate model remain together because the model needs all channels. This option does not split the series returned by one query. |
| `EXTRA_FILTERS` | One configured `reader.extra_filters` selector, with the full model/query/scheduler topology retained | Partition the series returned by large queries, for example by region, cluster, another stable label, or by [VictoriaMetrics tenant](https://docs.victoriametrics.com/victoriametrics/cluster-victoriametrics/#multitenancy-via-labels) using `vm_account_id` and `vm_project_id` selectors with the multitenant endpoint. The filters must already be defined in the global configuration. |
| `COMPLETE` | One valid scheduler/model/query combination; [multivariate](https://docs.victoriametrics.com/anomaly-detection/components/models/#multivariate-models) query sets remain together | Obtain the finest general-purpose split and the default choice for balanced sharding. `reader.extra_filters` are intentionally not expanded by this strategy. |
After the selected strategy creates the sub-configurations, they are assigned to members in deterministic round-robin order and then replicated according to `VMANOMALY_REPLICATION_FACTOR`.
### Splitting strategies
{{% collapse name="Configuration and resulting sub-configurations" %}}
The following abbreviated global configuration contains two schedulers, two models, four queries, and two data partitions:
```yaml
schedulers:
fast:
class: periodic
infer_every: 1m
fit_every: 1000d
fit_window: 1d
seasonal:
class: periodic
infer_every: 5m
fit_every: 1000d
fit_window: 2w
models:
cpu_zscore:
class: zscore_online
schedulers: [fast]
queries: [cpu, error_rate]
decay: 0.99
gpu_envelope:
class: temporal_envelope_multivariate
schedulers: [seasonal]
queries: [temperature, power]
seasonalities: [hod_smooth, dow_smooth]
reader:
class: vm
datasource_url: http://victoriametrics:8428/
sampling_period: 1m
queries:
cpu:
expr: avg(rate(node_cpu_seconds_total[5m])) by (instance)
error_rate:
expr: rate(application_errors_total[5m])
temperature:
expr: avg(gpu_temperature_celsius) by (gpu)
power:
expr: avg(gpu_power_watts) by (gpu)
extra_filters: ['{region="us-east"}', '{region="eu-west"}']
writer:
class: vm
datasource_url: http://victoriametrics:8428/
```
For this configuration, each strategy produces the following logical units before they are assigned to shards:
| Value | Resulting sub-configurations |
| --- | --- |
| `SCHEDULERS` | `fast`; `seasonal` |
| `MODELS` | `cpu_zscore`; `gpu_envelope` |
| `QUERIES` | `cpu`; `error_rate`; the multivariate set `power,temperature` |
| `EXTRA_FILTERS` | `{region="us-east"}`; `{region="eu-west"}`; each retains all schedulers, models, and queries, while the query context is restricted by its selector |
| `COMPLETE` | `fast:cpu_zscore:cpu`; `fast:cpu_zscore:error_rate`; `seasonal:gpu_envelope:power,temperature` |
For example, choose the query split with:
```yaml
environment:
VMANOMALY_MEMBERS_COUNT: 3
VMANOMALY_MEMBER_NUM: 0
VMANOMALY_REPLICATION_FACTOR: 1
VMANOMALY_SPLIT_BY: QUERIES
```
To partition the timeseries returned by the same large query instead, define non-overlapping selectors in `reader.extra_filters` and use `VMANOMALY_SPLIT_BY: EXTRA_FILTERS`. Each generated sub-configuration keeps one selector, for example `{region="us-east"}` or `{region="eu-west"}`.
{{% /collapse %}}
---
@@ -130,6 +219,7 @@ When `VMANOMALY_REPLICATION_FACTOR` > 1, each [sub-config](#sub-configuration) `
{{% content "vmanomaly-sharding-ha-diagram.md" %}}
> [!WARNING]
> Please [refer to deployment options section](#deployment-options) for the examples (Docker, Docker Compose, Helm). To avoid duplicate metrics being reported from each vmanomaly service used in sharded mode, make sure that [deduplication](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#deduplication) is configured on vmsingle or vmselect and vmstorage for the VictoriaMetrics instance used in the [writer section of the configuration](https://docs.victoriametrics.com/anomaly-detection/components/writer/).
### Example
@@ -198,7 +288,11 @@ services:
user: "1000:1000"
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8490/health"]
test:
- "CMD"
- "curl"
- "-f"
- "http://127.0.0.1:8490/health"
interval: 30s
timeout: 10s
retries: 5
@@ -218,7 +312,11 @@ services:
user: "1000:1000"
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8490/health"]
test:
- "CMD"
- "curl"
- "-f"
- "http://127.0.0.1:8490/health"
interval: 30s
timeout: 10s
retries: 5

View File

@@ -137,13 +137,13 @@ users:
password: '<password>'
url_map:
- src_hosts:
- "metrics.local.some-domain.net"
- "metrics.local.some-domain.net"
url_prefix: "http://victoriametrics:8428"
- src_hosts:
- "vl.local.some-domain.net"
- "vl.local.some-domain.net"
url_prefix: "http://victorialogs:9428"
- src_hosts:
- "vmanomaly.local.some-domain.net"
- "vmanomaly.local.some-domain.net"
url_prefix: "http://vmanomaly:8490"
keep_original_host: true
```
@@ -193,7 +193,7 @@ The best applications of this mode are:
### What you can do with Copilot
- **Ask questions** about any model (e.g. [Temporal Envelope](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope), [Prophet](https://docs.victoriametrics.com/anomaly-detection/components/models/#prophet), or [Z-score](https://docs.victoriametrics.com/anomaly-detection/components/models/#online-z-score) - parameters, trade-offs, when to use each)
- **Ask questions** about any model (e.g. [Temporal Envelope](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope), [Online Seasonal Quantile](https://docs.victoriametrics.com/anomaly-detection/components/models/#online-seasonal-quantile), or [Online Z-score](https://docs.victoriametrics.com/anomaly-detection/components/models/#online-z-score) - parameters, trade-offs, when to use each)
- **Improve detection quality** - describe what's wrong ("too many false positives", "missing spikes") and Copilot reads the config, searches the docs, and proposes a validated configuration change to fix the issue.
- **Get config suggestions inline** - suggestions appear as interactive cards with an explanation and a YAML diff; click **Apply** to write the change directly to your current settings, or **Decline** to keep the conversation going.
- {{% available_from "v1.30.0" anomaly %}} **Profile and tune the real query** - with [mcp-vmanomaly](#mcp-tools-server) connected, Copilot can inspect bounded time-series characteristics, recommend an online model, start an asynchronous autotune task, and apply its validated query and model suggestions.
@@ -316,7 +316,7 @@ docker run -it --rm \
-e VMANOMALY_MCP_SERVER_URL=http://mcp-vmanomaly:8081/mcp \
-p 8080:8080 \
-p 8490:8490 \
victoriametrics/vmanomaly:v1.30.0 \
victoriametrics/vmanomaly:v1.30.2 \
vmanomaly_config.yaml
```
@@ -569,7 +569,7 @@ Set up the time range and resolution (step) for data visualization and anomaly d
![vmanomaly-ui-sections-explore](vmanomaly-ui-sections-explore.webp)
Pay attention to trends, seasonality, noise, outliers, and other patterns in the data, which can influence the choice of anomaly detection model and its hyperparameters (e.g. use seasonal models for seasonal data - like `Prophet`, robust models for noisy de-seasonalized data - like `MAD`, etc.).
Pay attention to trends, seasonality, noise, outliers, and other patterns in the data, which can influence the choice of anomaly detection model and its hyperparameters. Use [Temporal Envelope](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope) for complex data with trend or calendar patterns, and [Online MAD](https://docs.victoriametrics.com/anomaly-detection/components/models/#online-mad) for simple, mostly stationary data where robustness to outliers matters.
![vmanomaly-ui-sections-plot-area-query-mode](vmanomaly-ui-sections-plot-area-query-mode.webp)
@@ -645,6 +645,26 @@ If the **results** look good and the **model configuration should be deployed in
{{% collapse name="Release history" %}}
### v1.8.2
Released: 2026-08-13
vmanomaly version: [v1.30.2](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1302)
- BUGFIX: Fixed tenant discovery for VictoriaMetrics datasource URLs containing `/select/multitenant/prometheus`. The UI now loads available numeric tenants from `/admin/tenants` and can switch the datasource URL from `multitenant` to the selected tenant.
### v1.8.1
Released: 2026-08-06
vmanomaly version: [v1.30.1](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1301)
- IMPROVEMENT: Model settings are validated and normalized when applied. Invalid drafts remain open with actionable feedback, and advanced-setting summaries open the corresponding editor directly.
- BUGFIX: Server-query counts load when the query drawer opens, and numeric model fields preserve valid scalar and range values while reporting parsing errors on blur.
- BUGFIX: The anomaly visualization empty state now follows the active theme instead of using light-theme colors in dark mode.
- BUGFIX: Tenant selection now follows the datasource URL resolved by the server, avoiding an incorrect switch to tenant `0` when it is unavailable.
### v1.8.0
Released: 2026-07-23

View File

@@ -37,6 +37,7 @@ The following minimal configuration demonstrates current many-to-many model, que
```yaml
settings:
n_workers: 4 # number of workers to run models in parallel
native_threads_per_worker: 0 # automatically divide container-aware CPU capacity across workers
anomaly_score_outside_data_range: 5.0 # default anomaly score for anomalies outside expected data range
restore_state: True # restore state from previous run, if available
retention: # how long to keep stale models on disk/in memory
@@ -50,15 +51,15 @@ schedulers:
class: 'periodic' # scheduler class
infer_every: "30s" # how often to produce anomaly scores for new data
scatter_infer_jobs: true # distribute infer jobs evenly across the infer interval to reduce synchronized bursts
fit_every: "365d" # how often to re-fit the models, for online models used effectively once, then they are updated with new data and won't require re-fit
fit_every: "1000d" # bootstrap-only schedule; use a finite cadence if accumulated state must be reset
fit_window: "3d" # how much historical data to use for fit stage
start_from: "00:00" # align the annual fit schedule to midnight in the configured timezone
start_from: "00:00" # align the bootstrap fit to midnight in the configured timezone
tz: "Europe/Kyiv" # timezone to use for start_from
periodic_offline_1w:
periodic_online_weekly:
class: 'periodic'
infer_every: "15m"
scatter_infer_jobs: true
fit_every: "24h"
fit_every: "1000d" # bootstrap-only schedule; use a finite cadence if accumulated state must be reset
fit_window: "14d"
# if no start_from is specified, jobs will start immediately after service starts
@@ -72,21 +73,17 @@ models:
provide_series: ['anomaly_score', 'y', 'yhat', 'yhat_upper'] # what series to produce as output of the model
queries: ['host_network_receive_errors'] # what queries to run particular model on
schedulers: ['periodic_online'] # will be fit once, used for infer every 30s
min_dev_from_expected: 0.0 # turned off. if |y - yhat| < min_dev_from_expected, anomaly score will be 0
detection_direction: 'above_expected' # detect anomalies only when y > yhat, "peaks"
clip_predictions: True # clip predictions to expected data range, i.e. [0, inf] for this query `host_network_receive_errors
prophet_weekly: # we can set up alias for model
class: 'prophet'
envelope_weekly: # we can set up alias for model
class: 'temporal_envelope'
alpha: 0.005 # adapt the trend while using the bootstrap-only fit schedule
loss_reactivity: 3 # allow new deviations to update the envelope
provide_series: ['anomaly_score', 'y', 'yhat', 'yhat_lower', 'yhat_upper']
queries: ['cpu_seconds_total']
schedulers: ['periodic_offline_1w'] # will be attached to 1-week scheduler, re-fit every 24h and infer every 15m
min_dev_from_expected: [0.01, 0.01] # minimum deviation from expected value to be even considered as anomaly
schedulers: ['periodic_online_weekly'] # fit on two weekly cycles, then update online every 15m
anomaly_score_outside_data_range: 1.5 # override default anomaly score outside expected data range
detection_direction: 'above_expected'
clip_predictions: True # clip predictions to expected data range, i.e. [0, inf] for this query `cpu_seconds_total`
args: # model-specific arguments
interval_width: 0.98
yearly_seasonality: False # disable yearly seasonality, since we have only 7 days of data
seasonalities: ['hod_smooth', 'dow_smooth']
# where to read data from
# https://docs.victoriametrics.com/anomaly-detection/components/reader/#vm-reader
@@ -95,6 +92,7 @@ reader:
datasource_url: "https://play.victoriametrics.com/"
tenant_id: "0:0"
sampling_period: "30s" # what data resolution to fetch from VictoriaMetrics' /query_range endpoint
workers: 0 # automatically choose bounded datasource concurrency
latency_offset: '1ms'
query_from_last_seen_timestamp: False
tz: "UTC" # timezone to use for queries without explicit timezone
@@ -103,17 +101,21 @@ reader:
cpu_seconds_total:
expr: 'avg(rate(node_cpu_seconds_total[5m])) by (mode)'
# step: '30s' # if not set, will be equal to reader-level sampling_period
data_range: [0, 'inf'] # expected value range, anomaly_score = anomaly_score_outside_data_range if y (real value) is outside
data_range: [0, 'inf'] # query-level business policy from v1.30.2
detection_direction: 'above_expected' # query-level from v1.30.2; detect spikes only
min_dev_from_expected: [0.01, 0.01] # query-level from v1.30.2
host_network_receive_errors:
expr: 'rate(node_network_receive_errs_total[3m]) / rate(node_network_receive_packets_total[3m])'
step: '15m' # here we override per-query `sampling_period` to request way less data from VM TSDB
data_range: [0, 'inf']
data_range: [0, 'inf'] # query-level business policy from v1.30.2
detection_direction: 'above_expected' # query-level from v1.30.2; detect spikes only
min_dev_from_expected: 0.0 # query-level from v1.30.2; absolute-deviation filtering is disabled
# where to write data to
# https://docs.victoriametrics.com/anomaly-detection/components/writer/
writer:
datasource_url: "http://victoriametrics:8428/"
# tenant_id: "0:0" # for VictoriaMetrics cluster, can support "multitenant"
tenant_id: "0:0" # for VictoriaMetrics cluster, can support "multitenant"
# https://docs.victoriametrics.com/anomaly-detection/components/writer/#metrics-formatting
metric_format:
__name__: $VAR
@@ -148,7 +150,7 @@ server:
{{% available_from "v1.25.0" anomaly %}} The service supports hot reload of configuration files, applying changes without an explicit restart. Enable it with the `--watch` [CLI argument](https://docs.victoriametrics.com/anomaly-detection/quickstart/#command-line-arguments). The `vmanomaly_config_reload_enabled` [self-monitoring metric](https://docs.victoriametrics.com/anomaly-detection/components/monitoring/#startup-metrics) is `1` when hot reload is enabled and `0` otherwise.
> [!NOTE]
> [!WARNING]
> {{% deprecated_from "v1.29.5" anomaly %}} File system event-based hot reload has been deprecated in favor of content-based polling with configurable `-configCheckInterval` due to reliability issues with Kubernetes ConfigMap symlink rotations and other filesystems where event delivery can be inconsistent. If you were using file system event-based hot reload, please switch to content-based polling by enabling `--watch` flag and configuring `-configCheckInterval` as needed.
### How it works
@@ -175,7 +177,7 @@ schedulers:
periodic:
class: 'periodic'
infer_every: "30s"
fit_every: "365d"
fit_every: "1000d" # bootstrap-only schedule; use a finite cadence if accumulated state must be reset
fit_window: "24h"
reader:
@@ -204,6 +206,7 @@ models:
writer:
datasource_url: "http://victoriametrics:8428/"
tenant_id: "0:0"
monitoring:
push:

File diff suppressed because it is too large Load Diff

View File

@@ -333,6 +333,14 @@ For detailed guidance on configuring mTLS parameters such as `verify_tls`, `tls_
<tr>
<td>
<span style="white-space: nowrap;">`vmanomaly_native_threads_per_worker`</span>
</td>
<td>Gauge</td>
<td>Effective maximum native numerical-library threads per model worker{{% available_from "v1.30.2" anomaly %}} after resolving [`settings.native_threads_per_worker`](https://docs.victoriametrics.com/anomaly-detection/components/settings/#parallelization) against the effective worker count and container-aware CPU capacity.</td>
</tr>
<tr>
<td>
<span style="white-space: nowrap;">`vmanomaly_config_entities`</span>
</td>
<td>Gauge</td>
@@ -588,7 +596,7 @@ Label names [description](#labelnames)
`Counter`
</td>
<td>The number of valid datapoints accepted by `model_alias`, excluding NaN and Inf values, during `fit`, `infer`, or combined `fit_infer` execution for the `query_key` query.</td>
<td>The number of valid datapoints accepted by `model_alias`, excluding NaN and Inf values, during `fit`, `infer`, or combined `fit_infer` execution for the `query_key` query. During inference, only previously unseen valid rows are counted {{% available_from "v1.30.1" anomaly %}}.</td>
<td>
`stage`, `query_key`, `model_alias`, `scheduler_alias`, `preset`
@@ -687,7 +695,7 @@ Label names [description](#labelnames)
`Histogram` (was `Summary`{{% deprecated_from "v1.17.0" anomaly %}})
</td>
<td>The total time (in seconds) taken by write requests to VictoriaMetrics `url` for the `query_key` query within the specified scheduler `scheduler_alias`, in the `vmanomaly` service running in `preset` mode.
<td>The total time (in seconds) taken by write requests to VictoriaMetrics `url` for the `query_key` query within the specified scheduler `scheduler_alias`, in the `vmanomaly` service running in `preset` mode. Successful and handled failed attempts, including connection retries, are observed {{% available_from "v1.30.1" anomaly %}}.
</td>
<td>

View File

@@ -59,7 +59,7 @@ reader:
step: '10s' # individual step for this query, will be filled with `sampling_period` from the root level
data_range: ['-inf', 'inf'] # by default, no constraints applied on data range
tz: 'UTC' # by default, tz-free data is used throughout the model lifecycle
# new query-level arguments will be added in backward-compatible way in future releases
# from v1.30.2, explicitly add detection_direction and minimum-deviation policies here when needed
```
{{% /collapse %}}
@@ -85,9 +85,19 @@ There is change {{% available_from "v1.13.0" anomaly %}} of [`queries`](https://
> If not set explicitly (or if older config style prior to [v1.13.0](https://docs.victoriametrics.com/anomaly-detection/changelog/#v1130)) is used, then it is set to reader-level `data_range` arg{{% available_from "v1.18.1" anomaly %}}
> Configuring `data_range` in a model is {{% deprecated_from "v1.30.2" anomaly %}}. Configure it under `reader.queries.<alias>` so the KPI domain remains the same when the query is attached to different models. Existing model-level values remain compatible as model-local fallbacks when the query does not define an explicit value.
- `detection_direction`{{% available_from "v1.30.2" anomaly %}} (`both`, `above_expected`, or `below_expected`): controls whether deviations on both sides, only above the expected value, or only below it can produce anomaly scores. The default is `both`. See [detection direction](https://docs.victoriametrics.com/anomaly-detection/components/models/#detection-direction) for behavior details.
- `min_dev_from_expected`{{% available_from "v1.30.2" anomaly %}} (float or one/two-element list[float]): ignores deviations smaller than the configured absolute threshold. A scalar or one-element list applies to both directions; a two-element list configures lower and upper deviations separately. See [minimal deviation from expected](https://docs.victoriametrics.com/anomaly-detection/components/models/#minimal-deviation-from-expected).
- `min_rel_dev_from_expected`{{% available_from "v1.30.2" anomaly %}} (float or one/two-element list[float]): ignores deviations smaller than the configured percentage of the absolute expected value. A scalar or one-element list applies to both directions; a two-element list configures lower and upper percentages separately. See [minimal relative deviation from expected](https://docs.victoriametrics.com/anomaly-detection/components/models/#minimal-relative-deviation-from-expected).
> Configuring `detection_direction`, `min_dev_from_expected`, or `min_rel_dev_from_expected` in a model is {{% deprecated_from "v1.30.2" anomaly %}}. Query-level values are authoritative. Existing model-level values remain compatible only as model-local fallbacks for attached queries that do not define the corresponding policy.
- `max_points_per_query`{{% available_from "v1.17.0" anomaly %}} (int): Optional arg, overrides how `search.maxPointsPerTimeseries` flag{{% available_from "v1.14.1" anomaly %}} impacts `vmanomaly` on splitting long `fit_window` [queries](https://docs.victoriametrics.com/anomaly-detection/components/reader/#vm-reader) into smaller sub-intervals. This helps users avoid hitting the `search.maxQueryDuration` limit for individual queries by distributing initial query across multiple subquery requests with minimal overhead. Set less than `search.maxPointsPerTimeseries` if hitting `maxQueryDuration` limits. If set on a query-level, it overrides the global `max_points_per_query` (reader-level).
- `tz`{{% available_from "v1.18.0" anomaly %}} (string): this optional argument enables timezone specification per query, overriding the readers default `tz`. This setting helps to account for local timezone shifts, such as [DST](https://en.wikipedia.org/wiki/Daylight_saving_time), in models that are sensitive to seasonal variations (e.g., [`ProphetModel`](https://docs.victoriametrics.com/anomaly-detection/components/models/#prophet) or [`OnlineQuantileModel`](https://docs.victoriametrics.com/anomaly-detection/components/models/#online-seasonal-quantile)).
- `tz`{{% available_from "v1.18.0" anomaly %}} (string): this optional argument enables timezone specification per query, overriding the readers default `tz`. This setting helps to account for local timezone shifts, such as [DST](https://en.wikipedia.org/wiki/Daylight_saving_time), in models that are sensitive to seasonal variations (e.g., [`TemporalEnvelopeModel`](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope) or [`OnlineQuantileModel`](https://docs.victoriametrics.com/anomaly-detection/components/models/#online-seasonal-quantile)).
- `tenant_id` {{% available_from "v1.19.0" anomaly %}} (string): this optional argument enables tenant-level separation for queries (e.g. `query1` to get the data from tenant "0:0", `query2` - from tenant "1:0"). It works as follows:
- if *not set, inherits* reader-level `tenant_id`
@@ -115,7 +125,10 @@ reader:
ingestion_rate_t1:
expr: 'sum(rate(vm_rows_inserted_total[5m])) by (type) > 0'
step: '2m' # overrides global `sampling_period` of 1m
data_range: [10, 'inf'] # meaning only positive values > 10 are expected, i.e. a value `y` < 10 will trigger anomaly score > 1
data_range: [10, 'inf'] # query-level business policy from v1.30.2; y < 10 triggers anomaly score > 1
detection_direction: 'above_expected' # query-level from v1.30.2; only spikes can be anomalous
min_dev_from_expected: [0, 5] # query-level from v1.30.2; ignore upward deviations smaller than 5
min_rel_dev_from_expected: [0, 15] # query-level from v1.30.2; ignore upward deviations below 15%
max_points_per_query: 5000 # overrides reader-level value of 10000 for `ingestion_rate` query
tz: 'America/New_York' # to override reader-wise `tz`
tenant_id: '1:0' # overriding tenant_id to isolate data
@@ -302,6 +315,19 @@ Optional timeout {{% available_from "v1.30.0" anomaly %}} for post-fetch process
<tr>
<td>
<span style="white-space: nowrap;">`workers`</span>
</td>
<td>
`0`
</td>
<td>
Maximum concurrent datasource fetch threads {{% available_from "v1.30.2" anomaly %}}. `0` selects a bounded value automatically from the number of queries and available CPUs. A positive value sets an explicit cap for queries and disk-streamed split-query chunks.
</td>
</tr>
<tr>
<td>
<span style="white-space: nowrap;">`verify_tls`</span>
</td>
<td>
@@ -441,7 +467,7 @@ Optional arg{{% available_from "v1.17.0" anomaly %}} overrides how `search.maxPo
`UTC`
</td>
<td>
Optional argument {{% available_from "v1.18.0" anomaly %}} specifies the [IANA](https://nodatime.org/TimeZones) timezone to account for local shifts, like [DST](https://en.wikipedia.org/wiki/Daylight_saving_time), in models sensitive to seasonal patterns (e.g., [`TemporalEnvelopeModel`](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope), [`ProphetModel`](https://docs.victoriametrics.com/anomaly-detection/components/models/#prophet), or [`OnlineQuantileModel`](https://docs.victoriametrics.com/anomaly-detection/components/models/#online-seasonal-quantile)). Defaults to `UTC` if not set and can be overridden on a [per-query basis](#per-query-parameters).
Optional argument {{% available_from "v1.18.0" anomaly %}} specifies the [IANA](https://nodatime.org/TimeZones) timezone to account for local shifts, like [DST](https://en.wikipedia.org/wiki/Daylight_saving_time), in models sensitive to seasonal patterns (e.g., [`TemporalEnvelopeModel`](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope) or [`OnlineQuantileModel`](https://docs.victoriametrics.com/anomaly-detection/components/models/#online-seasonal-quantile)). Defaults to `UTC` if not set and can be overridden on a [per-query basis](#per-query-parameters).
</td>
</tr>
<tr>
@@ -510,6 +536,7 @@ reader:
timeout: '30s' # backward-compatible default for both phases
fetch_timeout: '30s' # timeout for each datasource request, overrides `timeout` if set
processing_timeout: '1m' # timeout for preparing fetched series for fit/infer, overrides `timeout` if set
workers: 0 # automatic bounded datasource concurrency; set a positive value for an explicit cap
query_from_last_seen_timestamp: True # false by default
latency_offset: '1ms'
series_processing_batch_size: 8
@@ -825,7 +852,7 @@ Frequency of the points returned. Will be converted to `/select/stats_query_rang
`America/New_York`
</td>
<td>
(Optional) Specifies the [IANA](https://nodatime.org/TimeZones) timezone to account for local shifts, like [DST](https://en.wikipedia.org/wiki/Daylight_saving_time), in models sensitive to seasonal patterns (e.g., [`ProphetModel`](https://docs.victoriametrics.com/anomaly-detection/components/models/#prophet) or [`OnlineQuantileModel`](https://docs.victoriametrics.com/anomaly-detection/components/models/#online-seasonal-quantile)). Defaults to `UTC` if not set and can be overridden on a [per-query basis](#per-query-parameters).
(Optional) Specifies the [IANA](https://nodatime.org/TimeZones) timezone to account for local shifts, like [DST](https://en.wikipedia.org/wiki/Daylight_saving_time), in models sensitive to seasonal patterns (e.g., [`TemporalEnvelopeModel`](https://docs.victoriametrics.com/anomaly-detection/components/models/#temporal-envelope) or [`OnlineQuantileModel`](https://docs.victoriametrics.com/anomaly-detection/components/models/#online-seasonal-quantile)). Defaults to `UTC` if not set and can be overridden on a [per-query basis](#per-query-parameters).
</td>
</tr>
<tr>
@@ -896,6 +923,19 @@ Optional timeout {{% available_from "v1.30.0" anomaly %}} for post-fetch process
<tr>
<td>
<span style="white-space: nowrap;">`workers`</span>
</td>
<td>
`0`
</td>
<td>
Maximum concurrent datasource fetch threads {{% available_from "v1.30.2" anomaly %}}. `0` selects a bounded value automatically from the number of queries and available CPUs. A positive value sets an explicit cap for queries and disk-streamed split-query chunks.
</td>
</tr>
<tr>
<td>
<span style="white-space: nowrap;">`verify_tls`</span>
</td>
<td>
@@ -1037,12 +1077,15 @@ reader:
timeout: '30s' # backward-compatible default for both phases
fetch_timeout: '30s' # timeout for each datasource request, overrides `timeout` if set
processing_timeout: '1m' # timeout for preparing fetched series for fit/infer, overrides `timeout` if set
workers: 0 # automatic bounded datasource concurrency; set a positive value for an explicit cap
queries:
# one query returning 1 result fields (avg_duration), it will have __name__ label (series name) as `duration_30m__avg`
duration_avg_30m:
expr: "* | stats avg(duration) as avg" # initial LogsQL expression
step: '2m' # overrides global `sampling_period` of 1m
data_range: [0, 'inf'] # meaning only positive values > 0 are expected, i.e. a value `y` < 0 will trigger anomaly score > 1
data_range: [0, 'inf'] # query-level business policy from v1.30.2; y < 0 triggers anomaly score > 1
detection_direction: 'above_expected' # query-level from v1.30.2
min_rel_dev_from_expected: [0, 20] # query-level from v1.30.2; ignore upward deviations below 20%
tz: 'America/New_York' # to override reader-wise `tz`
# tenant_id: '1:0' # overriding tenant_id to isolate data
# offset: '-15s' # to override reader-wise `offset` and query data 15 seconds earlier to account for data collection delays

View File

@@ -70,10 +70,13 @@ options={`"scheduler.periodic.PeriodicScheduler"`, `"scheduler.oneoff.OneoffSche
## Periodic scheduler
> [!WARNING]
> If `start_from` [parameter](#parameters-1) is used, it's suggested to also set `restore_state: true` in the [Settings section](https://docs.victoriametrics.com/anomaly-detection/components/settings/#state-restoration) of a config, so that the scheduler can restore its state from the previous run **if terminated or restarted in between scheduled runs** and continue producing anomaly scores without interruptions, otherwise the service will be idle until future `start_from` time is reached. E.g. if `start_from` is set to `20:00` and the service is started and then terminated and restarted at `20:30`, it will not produce any anomaly scores until the next day's `20:00` is reached (+23:30 of being idle), which introduces inconvenience for the users.
> {{% available_from "v1.30.0" anomaly %}} If a periodic scheduler worker exits unexpectedly, the service attempts bounded restarts with exponential backoff instead of shutting down unrelated schedulers. Monitor [`vmanomaly_scheduler_alive`](https://docs.victoriametrics.com/anomaly-detection/components/monitoring/#startup-metrics) and `vmanomaly_scheduler_restarts_total` to alert on persistent failures.
> {{% available_from "v1.30.1" anomaly %}} For exact-capable [online models](https://docs.victoriametrics.com/anomaly-detection/components/models/#online-models), `infer_every` is also the causal model-update cadence. If a delayed periodic job fetches several observations at once, they are processed on the same chronological grid used by exact backtesting rather than as one behaviorally different batch.
### Parameters
For periodic scheduler parameters are defined as differences in times, expressed in difference units, e.g. days, hours, minutes, seconds. Time granularity is defined by the last characters of a string. Examples: `"50s"` (seconds), `"4m"` (minutes), `"3h"` (hours), `"2d"` (days), `"1w"` (weeks).
@@ -194,6 +197,7 @@ This configuration specifies that `vmanomaly` will calculate a 14-day time windo
## Oneoff scheduler
> [!WARNING]
> As of latest version, the Oneoff scheduler can't be explicitly used with a combination of [stateful service](https://docs.victoriametrics.com/anomaly-detection/components/settings/#state-restoration). It is designed to run once and exit, so it does not maintain state across runs. A warning will be raised in logs and internal state for such scheduler will not be saved and restored upon restart. If you need to run the scheduler periodically and/or maintain state, consider using the [Periodic scheduler](#periodic-scheduler) instead.
### Parameters
@@ -365,6 +369,7 @@ schedulers:
> {{% available_from "v1.26.0" anomaly %}} `BacktestingScheduler` in [inference-only](https://docs.victoriametrics.com/anomaly-detection/components/scheduler/#inference-only-mode) mode is used in UI for backtesting configurations on historical data to verify that it works as expected before it goes live. See [vmanomaly UI](https://docs.victoriametrics.com/anomaly-detection/ui/) on how to access and use the UI.
> [!WARNING]
> As of latest version, the Backtesting scheduler can't be explicitly used with a combination of [state restoration](https://docs.victoriametrics.com/anomaly-detection/components/settings/#state-restoration). It is designed to run once and exit, so it does not maintain state across runs. A warning will be raised in logs and internal state for such scheduler will not be saved and restored upon restart. If you need to run the scheduler periodically and/or maintain state, consider using the [Periodic scheduler](#periodic-scheduler) instead.
> A new, more intuitive backtesting mode is available {{% available_from "v1.22.1" anomaly %}}. In **Inference only** mode, the window you specify via `[from, to]` (or `[from_iso, to_iso]`) is used *solely for inference*, and the corresponding training (“fit”) windows are determined automatically. To enable this behavior, set:
@@ -440,7 +445,7 @@ In **Inference only** mode {{% available_from "v1.22.1" anomaly %}}, the schedul
- `fit_window`: Duration of historical data used for each training run (e.g. `P7D`, `PT1H`).
- `fit_every`: Interval between consecutive training/inference cycles.
- {{% available_from "v1.28.0" anomaly %}} `exact`: If set to `true`, BacktestingScheduler will execute inference for online models in small chronological batches equal to `infer_every` to mimic the production scheduler. (default: `false`)
- {{% available_from "v1.28.0" anomaly %}} `infer_every`: Optional inference cadence for exact mode, defining how often the scheduler should call infer between two fits, otherwise defaults to `fit_every` when unset.
- {{% available_from "v1.28.0" anomaly %}} `infer_every`: Optional inference grid and, in exact mode, model-call cadence between two fits. {{% available_from "v1.30.1" anomaly %}} In `inference_only` mode, an omitted value is derived from the effective query step or reader sampling period and capped by `fit_every`; it falls back to `fit_every` only when neither reader value is available.
- `n_jobs`: Number of parallel jobs for backtesting (default: `1`).
#### Example

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Some files were not shown because too many files have changed in this diff Show More