Compare commits

..

1 Commits

Author SHA1 Message Date
dmitryk-dk
35bd7f37b1 apptest/vmctl: migrate vmctl test for the prometheus migration process
apptest/vmctl: wait until vmctl finish it work

apptest/vmctl: remove unneeded function

apptest/vmctl: fix linter

apptest/vmctl: add vmctl to build for the integration tests

apptest/vmctl: rename file

apptest/vmctl: fix comments

apptest/vmctl: added check of the wait discussed in the comments

apptest/vmctl: compare migrated data with expected response

apptest/vmctl: format expected_response.json
2025-06-02 16:37:02 +02:00
861 changed files with 368510 additions and 31106 deletions

View File

@@ -6,4 +6,4 @@ Please provide a brief description of the changes you made. Be as specific as po
The following checks are **mandatory**:
- [ ] My change adheres to [VictoriaMetrics contributing guidelines](https://docs.victoriametrics.com/victoriametrics/contributing/#pull-request-checklist).
- [ ] My change adheres to [VictoriaMetrics contributing guidelines](https://docs.victoriametrics.com/victoriametrics/contributing/).

View File

@@ -195,25 +195,6 @@ vmutils-crossbuild: \
vmutils-openbsd-amd64 \
vmutils-windows-amd64
publish-latest:
PKG_TAG=$(TAG) APP_NAME=victoria-metrics $(MAKE) publish-via-docker-latest && \
PKG_TAG=$(TAG) APP_NAME=vmagent $(MAKE) publish-via-docker-latest && \
PKG_TAG=$(TAG) APP_NAME=vmalert $(MAKE) publish-via-docker-latest && \
PKG_TAG=$(TAG) APP_NAME=vmalert-tool $(MAKE) publish-via-docker-latest && \
PKG_TAG=$(TAG) APP_NAME=vmauth $(MAKE) publish-via-docker-latest && \
PKG_TAG=$(TAG) APP_NAME=vmbackup $(MAKE) publish-via-docker-latest && \
PKG_TAG=$(TAG) APP_NAME=vmrestore $(MAKE) publish-via-docker-latest && \
PKG_TAG=$(TAG) APP_NAME=vmctl $(MAKE) publish-via-docker-latest && \
PKG_TAG=$(TAG)-cluster APP_NAME=vminsert $(MAKE) publish-via-docker-latest && \
PKG_TAG=$(TAG)-cluster APP_NAME=vmselect $(MAKE) publish-via-docker-latest && \
PKG_TAG=$(TAG)-cluster APP_NAME=vmstorage $(MAKE) publish-via-docker-latest && \
PKG_TAG=$(TAG)-enterprise APP_NAME=vmgateway $(MAKE) publish-via-docker-latest
PKG_TAG=$(TAG)-enterprise APP_NAME=vmbackupmanager $(MAKE) publish-via-docker-latest
publish-victoria-logs-latest:
PKG_TAG=$(TAG) APP_NAME=victoria-logs $(MAKE) publish-via-docker-latest
PKG_TAG=$(TAG) APP_NAME=vlogscli $(MAKE) publish-via-docker-latest
publish-release:
rm -rf bin/*
git checkout $(TAG) && $(MAKE) release && $(MAKE) publish && \
@@ -545,7 +526,7 @@ test-full:
test-full-386:
GOEXPERIMENT=synctest GOARCH=386 go test -coverprofile=coverage.txt -covermode=atomic ./lib/... ./app/...
integration-test: victoria-metrics vmagent vmalert vmauth vmctl vmbackup vmrestore victoria-logs
integration-test: victoria-metrics vmagent vmalert vmauth vmctl
go test ./apptest/... -skip="^TestCluster.*"
benchmark:

View File

@@ -40,16 +40,16 @@ VictoriaMetrics is optimized for timeseries data, even when old time series are
* **Easy to setup**: No dependencies, single [small binary](https://medium.com/@valyala/stripping-dependency-bloat-in-victoriametrics-docker-image-983fb5912b0d), configuration through command-line flags, but the default is also fine-tuned; backup and restore with [instant snapshots](https://medium.com/@valyala/how-victoriametrics-makes-instant-snapshots-for-multi-terabyte-time-series-data-e1f3fb0e0282).
* **Global query view**: Multiple Prometheus instances or any other data sources may ingest data into VictoriaMetrics and queried via a single query.
* **Various Protocols**: Support metric scraping, ingestion and backfilling in various protocol.
* [Prometheus exporters](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#how-to-scrape-prometheus-exporters-such-as-node-exporter), [Prometheus remote write API](https://docs.victoriametrics.com/victoriametrics/integrations/prometheus/), [Prometheus exposition format](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#how-to-import-data-in-prometheus-exposition-format).
* [InfluxDB line protocol](https://docs.victoriametrics.com/victoriametrics/integrations/influxdb/) over HTTP, TCP and UDP.
* [Prometheus exporters](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#how-to-scrape-prometheus-exporters-such-as-node-exporter), [Prometheus remote write API](https://docs.victoriametrics.com/victoriametrics/integrations/prometheus), [Prometheus exposition format](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#how-to-import-data-in-prometheus-exposition-format).
* [InfluxDB line protocol](https://docs.victoriametrics.com/victoriametrics/integrations/influxdb) over HTTP, TCP and UDP.
* [Graphite plaintext protocol](https://docs.victoriametrics.com/victoriametrics/integrations/graphite/#ingesting) with [tags](https://graphite.readthedocs.io/en/latest/tags.html#carbon).
* [OpenTSDB put message](https://docs.victoriametrics.com/victoriametrics/integrations/opentsdb/#sending-data-via-telnet).
* [HTTP OpenTSDB /api/put requests](https://docs.victoriametrics.com/victoriametrics/integrations/opentsdb/#sending-data-via-http).
* [OpenTSDB put message](https://docs.victoriametrics.com/victoriametrics/integrations/opentsdb#sending-data-via-telnet).
* [HTTP OpenTSDB /api/put requests](https://docs.victoriametrics.com/victoriametrics/integrations/opentsdb#sending-data-via-http).
* [JSON line format](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#how-to-import-data-in-json-line-format).
* [Arbitrary CSV data](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#how-to-import-csv-data).
* [Native binary format](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#how-to-import-data-in-native-format).
* [DataDog agent or DogStatsD](https://docs.victoriametrics.com/victoriametrics/integrations/datadog/).
* [NewRelic infrastructure agent](https://docs.victoriametrics.com/victoriametrics/integrations/newrelic/#sending-data-from-agent).
* [DataDog agent or DogStatsD](https://docs.victoriametrics.com/victoriametrics/integrations/datadog).
* [NewRelic infrastructure agent](https://docs.victoriametrics.com/victoriametrics/integrations/newrelic#sending-data-from-agent).
* [OpenTelemetry metrics format](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#sending-data-via-opentelemetry).
* **NFS-based storages**: Supports storing data on NFS-based storages such as Amazon EFS, Google Filestore.
* And many other features such as metrics relabeling, cardinality limiter, etc.

View File

@@ -8,7 +8,6 @@ import (
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutil"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlselect"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/buildinfo"
@@ -45,8 +44,6 @@ func main() {
vlstorage.Init()
vlselect.Init()
insertutil.SetLogRowsStorage(&vlstorage.Storage{})
vlinsert.Init()
go httpserver.Serve(listenAddrs, requestHandler, httpserver.ServeOptions{

View File

@@ -11,6 +11,7 @@ import (
"github.com/valyala/fastjson"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutil"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/bytesutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/flagutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
@@ -32,10 +33,10 @@ var parserPool fastjson.ParserPool
// RequestHandler processes Datadog insert requests
func RequestHandler(path string, w http.ResponseWriter, r *http.Request) bool {
switch path {
case "/insert/datadog/api/v1/validate":
case "/api/v1/validate":
fmt.Fprintf(w, `{}`)
return true
case "/insert/datadog/api/v2/logs":
case "/api/v2/logs":
return datadogLogsIngestion(w, r)
default:
return false
@@ -73,7 +74,7 @@ func datadogLogsIngestion(w http.ResponseWriter, r *http.Request) bool {
cp.IgnoreFields = *datadogIgnoreFields
}
if err := insertutil.CanWriteData(); err != nil {
if err := vlstorage.CanWriteData(); err != nil {
httpserver.Errorf(w, r, "%s", err)
return true
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/VictoriaMetrics/metrics"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutil"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/bufferedwriter"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/bytesutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
@@ -30,38 +31,36 @@ func RequestHandler(path string, w http.ResponseWriter, r *http.Request) bool {
// This header is needed for Logstash
w.Header().Set("X-Elastic-Product", "Elasticsearch")
if strings.HasPrefix(path, "/insert/elasticsearch/_ilm/policy") {
if strings.HasPrefix(path, "/_ilm/policy") {
// Return fake response for Elasticsearch ilm request.
fmt.Fprintf(w, `{}`)
return true
}
if strings.HasPrefix(path, "/insert/elasticsearch/_index_template") {
if strings.HasPrefix(path, "/_index_template") {
// Return fake response for Elasticsearch index template request.
fmt.Fprintf(w, `{}`)
return true
}
if strings.HasPrefix(path, "/insert/elasticsearch/_ingest") {
if strings.HasPrefix(path, "/_ingest") {
// Return fake response for Elasticsearch ingest pipeline request.
// See: https://www.elastic.co/guide/en/elasticsearch/reference/8.8/put-pipeline-api.html
fmt.Fprintf(w, `{}`)
return true
}
if strings.HasPrefix(path, "/insert/elasticsearch/_nodes") {
if strings.HasPrefix(path, "/_nodes") {
// Return fake response for Elasticsearch nodes discovery request.
// See: https://www.elastic.co/guide/en/elasticsearch/reference/8.8/cluster.html
fmt.Fprintf(w, `{}`)
return true
}
if strings.HasPrefix(path, "/insert/elasticsearch/logstash") || strings.HasPrefix(path, "/insert/elasticsearch/_logstash") {
if strings.HasPrefix(path, "/logstash") || strings.HasPrefix(path, "/_logstash") {
// Return fake response for Logstash APIs requests.
// See: https://www.elastic.co/guide/en/elasticsearch/reference/8.8/logstash-apis.html
fmt.Fprintf(w, `{}`)
return true
}
switch path {
// some clients may omit trailing slash
// see https://github.com/VictoriaMetrics/VictoriaMetrics/issues/8353
case "/insert/elasticsearch/", "/insert/elasticsearch":
case "/", "":
switch r.Method {
case http.MethodGet:
// Return fake response for Elasticsearch ping request.
@@ -76,7 +75,7 @@ func RequestHandler(path string, w http.ResponseWriter, r *http.Request) bool {
}
return true
case "/insert/elasticsearch/_license":
case "/_license":
// Return fake response for Elasticsearch license request.
fmt.Fprintf(w, `{
"license": {
@@ -87,7 +86,7 @@ func RequestHandler(path string, w http.ResponseWriter, r *http.Request) bool {
}
}`)
return true
case "/insert/elasticsearch/_bulk":
case "/_bulk":
startTime := time.Now()
bulkRequestsTotal.Inc()
@@ -96,7 +95,7 @@ func RequestHandler(path string, w http.ResponseWriter, r *http.Request) bool {
httpserver.Errorf(w, r, "%s", err)
return true
}
if err := insertutil.CanWriteData(); err != nil {
if err := vlstorage.CanWriteData(); err != nil {
httpserver.Errorf(w, r, "%s", err)
return true
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/VictoriaMetrics/metrics"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httputil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
@@ -35,7 +36,6 @@ type CommonParams struct {
DecolorizeFields []string
ExtraFields []logstorage.Field
IsTimeFieldSet bool
Debug bool
DebugRequestURI string
DebugRemoteAddr string
@@ -49,10 +49,8 @@ func GetCommonParams(r *http.Request) (*CommonParams, error) {
return nil, err
}
var isTimeFieldSet bool
timeFields := []string{"_time"}
if tfs := httputil.GetArray(r, "_time_field", "VL-Time-Field"); len(tfs) > 0 {
isTimeFieldSet = true
timeFields = tfs
}
@@ -88,11 +86,9 @@ func GetCommonParams(r *http.Request) (*CommonParams, error) {
IgnoreFields: ignoreFields,
DecolorizeFields: decolorizeFields,
ExtraFields: extraFields,
IsTimeFieldSet: isTimeFieldSet,
Debug: debug,
DebugRequestURI: debugRequestURI,
DebugRemoteAddr: debugRemoteAddr,
Debug: debug,
DebugRequestURI: debugRequestURI,
DebugRemoteAddr: debugRemoteAddr,
}
return cp, nil
@@ -145,29 +141,6 @@ func GetCommonParamsForSyslog(tenantID logstorage.TenantID, streamFields, ignore
return cp
}
// LogRowsStorage is an interface for ingesting logs into the storage.
type LogRowsStorage interface {
// MustAddRows must add lr to the underlying storage.
MustAddRows(lr *logstorage.LogRows)
// CanWriteData must returns non-nil error if logs cannot be added to the underlying storage.
CanWriteData() error
}
var logRowsStorage LogRowsStorage
// SetLogRowsStorage sets the storage for writing data to via LogMessageProcessor.
//
// This function must be called before using LogMessageProcessor and CanWriteData from this package.
func SetLogRowsStorage(storage LogRowsStorage) {
logRowsStorage = storage
}
// CanWriteData returns non-nil error if data cannot be written to the underlying storage.
func CanWriteData() error {
return logRowsStorage.CanWriteData()
}
// LogMessageProcessor is an interface for log message processors.
type LogMessageProcessor interface {
// AddRow must add row to the LogMessageProcessor with the given timestamp and fields.
@@ -291,7 +264,7 @@ func (lmp *logMessageProcessor) AddInsertRow(r *logstorage.InsertRow) {
// flushLocked must be called under locked lmp.mu.
func (lmp *logMessageProcessor) flushLocked() {
lmp.lastFlushTime = time.Now()
logRowsStorage.MustAddRows(lmp.lr)
vlstorage.MustAddRows(lmp.lr)
lmp.lr.ResetKeepSettings()
}

View File

@@ -56,7 +56,6 @@ func NewLineReader(name string, r io.Reader) *LineReader {
// Check for Err in this case.
func (lr *LineReader) NextLine() bool {
for {
lr.Line = nil
if lr.bufOffset >= len(lr.buf) {
if lr.err != nil || lr.eofReached {
return false
@@ -102,11 +101,9 @@ func (lr *LineReader) readMoreData() bool {
bufLen := len(lr.buf)
if bufLen >= MaxLineSizeBytes.IntN() {
ok, skippedBytes := lr.skipUntilNextLine()
logger.Warnf("%s: the line length exceeds -insert.maxLineSizeBytes=%d; skipping it; total skipped bytes=%d",
lr.name, MaxLineSizeBytes.IntN(), skippedBytes)
logger.Warnf("%s: the line length exceeds -insert.maxLineSizeBytes=%d; skipping it; line contents=%q", lr.name, MaxLineSizeBytes.IntN(), lr.buf)
tooLongLinesSkipped.Inc()
return ok
return lr.skipUntilNextLine()
}
lr.buf = slicesutil.SetLength(lr.buf, MaxLineSizeBytes.IntN())
@@ -124,35 +121,26 @@ func (lr *LineReader) readMoreData() bool {
var tooLongLinesSkipped = metrics.NewCounter("vl_too_long_lines_skipped_total")
func (lr *LineReader) skipUntilNextLine() (bool, int) {
// Initialize skipped bytes count with MaxLineSizeBytes because
// we've already read that many bytes without encountering a newline,
// indicating the line size exceeds the maximum allowed limit.
skipSizeBytes := MaxLineSizeBytes.IntN()
func (lr *LineReader) skipUntilNextLine() bool {
for {
lr.buf = slicesutil.SetLength(lr.buf, MaxLineSizeBytes.IntN())
n, err := lr.r.Read(lr.buf)
skipSizeBytes += n
lr.buf = lr.buf[:n]
if err != nil {
if errors.Is(err, io.EOF) {
lr.eofReached = true
lr.buf = lr.buf[:0]
return true, skipSizeBytes
return true
}
lr.err = fmt.Errorf("cannot skip the current line: %s", err)
return false, skipSizeBytes
return false
}
if n := bytes.IndexByte(lr.buf, '\n'); n >= 0 {
// Include skipped bytes before \n, including the newline itself.
skipSizeBytes += n + 1 - len(lr.buf)
// Include \n in the buf, so too long line is replaced with an empty line.
// This is needed for maintaining synchorinzation consistency between lines
// in protocols such as Elasticsearch bulk import.
lr.buf = append(lr.buf[:0], lr.buf[n:]...)
return true, skipSizeBytes
return true
}
}
}

View File

@@ -24,9 +24,6 @@ func TestLineReader_Success(t *testing.T) {
if lr.NextLine() {
t.Fatalf("expecting error on the second call to NextLine()")
}
if len(lr.Line) > 0 {
t.Fatalf("unexpected non-empty line after failed NextLine(): %q", lr.Line)
}
if !reflect.DeepEqual(lines, linesExpected) {
t.Fatalf("unexpected lines\ngot\n%q\nwant\n%q", lines, linesExpected)
}

View File

@@ -38,10 +38,7 @@ func ExtractTimestampFromFields(timeFields []string, fields []logstorage.Field)
}
func parseTimestamp(s string) (int64, error) {
// "-" is a nil timestamp value, if the syslog
// application is incapable of obtaining system time
// https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.3
if s == "" || s == "0" || s == "-" {
if s == "" || s == "0" {
return time.Now().UnixNano(), nil
}
if len(s) <= len("YYYY") || s[len("YYYY")] != '-' {

View File

@@ -133,33 +133,6 @@ func TestExtractTimestampFromFields_Success(t *testing.T) {
}, 1718773640000000000)
}
func TestExtractTimestampFromFields_Now(t *testing.T) {
f := func(timeField string, fields []logstorage.Field) {
t.Helper()
nsecs, err := ExtractTimestampFromFields([]string{timeField}, fields)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if nsecs < 1 {
t.Fatalf("expected generated timestamp, got error: %s", err)
}
}
// RFC5424 allows `-` for nil timestamp (log ingestion time)
f("time", []logstorage.Field{
{Name: "time", Value: "-"},
})
f("time", []logstorage.Field{
{Name: "time", Value: ""},
})
f("time", []logstorage.Field{
{Name: "time", Value: "0"},
})
}
func TestExtractTimestampFromFields_Error(t *testing.T) {
f := func(s string) {
t.Helper()

View File

@@ -1,6 +1,7 @@
package internalinsert
import (
"flag"
"fmt"
"net/http"
"time"
@@ -8,6 +9,7 @@ import (
"github.com/VictoriaMetrics/metrics"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutil"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage/netinsert"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/flagutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
@@ -16,11 +18,17 @@ import (
)
var (
disableInsert = flag.Bool("internalinsert.disable", false, "Whether to disable /internal/insert HTTP endpoint")
maxRequestSize = flagutil.NewBytes("internalinsert.maxRequestSize", 64*1024*1024, "The maximum size in bytes of a single request, which can be accepted at /internal/insert HTTP endpoint")
)
// RequestHandler processes /internal/insert requests.
func RequestHandler(w http.ResponseWriter, r *http.Request) {
if *disableInsert {
httpserver.Errorf(w, r, "requests to /internal/insert are disabled with -internalinsert.disable command-line flag")
return
}
startTime := time.Now()
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
@@ -39,7 +47,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) {
httpserver.Errorf(w, r, "%s", err)
return
}
if err := insertutil.CanWriteData(); err != nil {
if err := vlstorage.CanWriteData(); err != nil {
httpserver.Errorf(w, r, "%s", err)
return
}

View File

@@ -3,48 +3,29 @@ package journald
import (
"bytes"
"encoding/binary"
"errors"
"flag"
"fmt"
"io"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutil"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/bytesutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/flagutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/protoparserutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/writeconcurrencylimiter"
"github.com/VictoriaMetrics/metrics"
)
// See https://github.com/systemd/systemd/blob/main/src/libsystemd/sd-journal/journal-file.c#L1703
const maxFieldNameLen = 64
const journaldEntryMaxNameLen = 64
func isValidJournaldFieldName(s string) bool {
if len(s) == 0 {
return false
}
c := s[0]
if !(c >= 'A' && c <= 'Z' || c == '_') {
return false
}
for i := 1; i < len(s); i++ {
c := s[i]
if !(c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_') {
return false
}
}
return true
}
var allowedJournaldEntryNameChars = regexp.MustCompile(`^[A-Z_][A-Z0-9_]*`)
var (
journaldStreamFields = flagutil.NewArrayString("journald.streamFields", "Comma-separated list of fields to use as log stream fields for logs ingested over journald protocol. "+
@@ -55,7 +36,9 @@ var (
"See https://docs.victoriametrics.com/victorialogs/data-ingestion/journald/#time-field")
journaldTenantID = flag.String("journald.tenantID", "0:0", "TenantID for logs ingested via the Journald endpoint. "+
"See https://docs.victoriametrics.com/victorialogs/data-ingestion/journald/#multitenancy")
journaldIncludeEntryMetadata = flag.Bool("journald.includeEntryMetadata", false, "Include Journald fields with double underscore prefixes")
journaldIncludeEntryMetadata = flag.Bool("journald.includeEntryMetadata", false, "Include journal entry fields, which with double underscores.")
maxRequestSize = flagutil.NewBytes("journald.maxRequestSize", 64*1024*1024, "The maximum size in bytes of a single journald request")
)
func getCommonParams(r *http.Request) (*insertutil.CommonParams, error) {
@@ -70,12 +53,11 @@ func getCommonParams(r *http.Request) (*insertutil.CommonParams, error) {
}
cp.TenantID = tenantID
}
if !cp.IsTimeFieldSet {
if len(cp.TimeFields) == 0 {
cp.TimeFields = []string{*journaldTimeField}
}
if len(cp.StreamFields) == 0 {
cp.StreamFields = getStreamFields()
cp.StreamFields = *journaldStreamFields
}
if len(cp.IgnoreFields) == 0 {
cp.IgnoreFields = *journaldIgnoreFields
@@ -84,23 +66,10 @@ func getCommonParams(r *http.Request) (*insertutil.CommonParams, error) {
return cp, nil
}
func getStreamFields() []string {
if len(*journaldStreamFields) > 0 {
return *journaldStreamFields
}
return defaultStreamFields
}
var defaultStreamFields = []string{
"_MACHINE_ID",
"_HOSTNAME",
"_SYSTEMD_UNIT",
}
// RequestHandler processes Journald Export insert requests
func RequestHandler(path string, w http.ResponseWriter, r *http.Request) bool {
switch path {
case "/insert/journald/upload":
case "/upload":
if r.Header.Get("Content-Type") != "application/vnd.fdo.journal" {
httpserver.Errorf(w, r, "only application/vnd.fdo.journal encoding is supported for Journald")
return true
@@ -115,7 +84,7 @@ func RequestHandler(path string, w http.ResponseWriter, r *http.Request) bool {
// handleJournald parses Journal binary entries
func handleJournald(r *http.Request, w http.ResponseWriter) {
startTime := time.Now()
requestsTotal.Inc()
requestsJournaldTotal.Inc()
cp, err := getCommonParams(r)
if err != nil {
@@ -124,25 +93,19 @@ func handleJournald(r *http.Request, w http.ResponseWriter) {
return
}
if err := insertutil.CanWriteData(); err != nil {
if err := vlstorage.CanWriteData(); err != nil {
errorsTotal.Inc()
httpserver.Errorf(w, r, "%s", err)
return
}
encoding := r.Header.Get("Content-Encoding")
reader, err := protoparserutil.GetUncompressedReader(r.Body, encoding)
if err != nil {
errorsTotal.Inc()
logger.Errorf("cannot decode journald request: %s", err)
return
}
lmp := cp.NewLogMessageProcessor("journald", true)
streamName := fmt.Sprintf("remoteAddr=%s, requestURI=%q", httpserver.GetQuotedRemoteAddr(r), r.RequestURI)
err = processStreamInternal(streamName, reader, lmp, cp)
protoparserutil.PutUncompressedReader(reader)
lmp.MustClose()
err = protoparserutil.ReadUncompressedData(r.Body, encoding, maxRequestSize, func(data []byte) error {
lmp := cp.NewLogMessageProcessor("journald", false)
err := parseJournaldRequest(data, lmp, cp)
lmp.MustClose()
return err
})
if err != nil {
errorsTotal.Inc()
httpserver.Errorf(w, r, "cannot read journald protocol data: %s", err)
@@ -154,180 +117,102 @@ func handleJournald(r *http.Request, w http.ResponseWriter) {
// See https://github.com/systemd/systemd/pull/34822
w.Header().Set("Accept-Encoding", "zstd")
// update requestDuration only for successfully parsed requests
// There is no need in updating requestDuration for request errors,
// update requestJournaldDuration only for successfully parsed requests
// There is no need in updating requestJournaldDuration for request errors,
// since their timings are usually much smaller than the timing for successful request parsing.
requestDuration.UpdateDuration(startTime)
requestJournaldDuration.UpdateDuration(startTime)
}
var (
requestsTotal = metrics.NewCounter(`vl_http_requests_total{path="/insert/journald/upload"}`)
errorsTotal = metrics.NewCounter(`vl_http_errors_total{path="/insert/journald/upload"}`)
requestDuration = metrics.NewHistogram(`vl_http_request_duration_seconds{path="/insert/journald/upload"}`)
requestsJournaldTotal = metrics.NewCounter(`vl_http_requests_total{path="/insert/journald/upload"}`)
errorsTotal = metrics.NewCounter(`vl_http_errors_total{path="/insert/journald/upload"}`)
requestJournaldDuration = metrics.NewHistogram(`vl_http_request_duration_seconds{path="/insert/journald/upload"}`)
)
func processStreamInternal(streamName string, r io.Reader, lmp insertutil.LogMessageProcessor, cp *insertutil.CommonParams) error {
wcr := writeconcurrencylimiter.GetReader(r)
defer writeconcurrencylimiter.PutReader(wcr)
lr := insertutil.NewLineReader("journald", wcr)
for {
err := readJournaldLogEntry(streamName, lr, lmp, cp)
wcr.DecConcurrency()
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return fmt.Errorf("%s: %w", streamName, err)
}
}
}
type fieldsBuf struct {
fields []logstorage.Field
buf []byte
name []byte
value []byte
}
func (fb *fieldsBuf) reset() {
fb.fields = fb.fields[:0]
fb.buf = fb.buf[:0]
fb.name = fb.name[:0]
fb.value = fb.value[:0]
}
func (fb *fieldsBuf) addField(name, value string) {
bufLen := len(fb.buf)
fb.buf = append(fb.buf, name...)
nameCopy := bytesutil.ToUnsafeString(fb.buf[bufLen:])
bufLen = len(fb.buf)
fb.buf = append(fb.buf, value...)
valueCopy := bytesutil.ToUnsafeString(fb.buf[bufLen:])
fb.fields = append(fb.fields, logstorage.Field{
Name: nameCopy,
Value: valueCopy,
})
}
func getFieldsBuf() *fieldsBuf {
fb := fieldsBufPool.Get()
if fb == nil {
return &fieldsBuf{}
}
return fb.(*fieldsBuf)
}
func putFieldsBuf(fb *fieldsBuf) {
fb.reset()
fieldsBufPool.Put(fb)
}
var fieldsBufPool sync.Pool
// readJournaldLogEntry reads a single log entry in Journald format.
//
// See https://systemd.io/JOURNAL_EXPORT_FORMATS/#journal-export-format
func readJournaldLogEntry(streamName string, lr *insertutil.LineReader, lmp insertutil.LogMessageProcessor, cp *insertutil.CommonParams) error {
func parseJournaldRequest(data []byte, lmp insertutil.LogMessageProcessor, cp *insertutil.CommonParams) error {
var fields []logstorage.Field
var ts int64
var size uint64
var name, value string
var line []byte
fb := getFieldsBuf()
defer putFieldsBuf(fb)
currentTimestamp := time.Now().UnixNano()
if !lr.NextLine() {
if err := lr.Err(); err != nil {
return fmt.Errorf("cannot read the first field: %w", err)
}
return io.EOF
}
for {
line := lr.Line
if len(line) == 0 {
// The end of a single log entry. Write it to the storage
if len(fb.fields) > 0 {
for len(data) > 0 {
idx := bytes.IndexByte(data, '\n')
switch {
case idx > 0:
// process fields
line = data[:idx]
data = data[idx+1:]
case idx == 0:
// next message or end of file
// double new line is a separator for the next message
if len(fields) > 0 {
if ts == 0 {
ts = time.Now().UnixNano()
ts = currentTimestamp
}
lmp.AddRow(ts, fb.fields, nil)
lmp.AddRow(ts, fields, nil)
fields = fields[:0]
}
return nil
// skip newline separator
data = data[1:]
continue
case idx < 0:
return fmt.Errorf("missing new line separator, unread data left=%d", len(data))
}
// line could be either "key=value\n" or "key\n<little_endian_size_64>value\n"
// according to https://systemd.io/JOURNAL_EXPORT_FORMATS/#journal-export-format
if n := bytes.IndexByte(line, '='); n >= 0 {
// "key=value\n"
fb.name = append(fb.name[:0], line[:n]...)
name = bytesutil.ToUnsafeString(fb.name)
fb.value = append(fb.value[:0], line[n+1:]...)
value = bytesutil.ToUnsafeString(fb.value)
idx = bytes.IndexByte(line, '=')
// could b either e key=value\n pair
// or just key\n
// with binary data at the buffer
if idx > 0 {
name = bytesutil.ToUnsafeString(line[:idx])
value = bytesutil.ToUnsafeString(line[idx+1:])
} else {
// "key\n<little_endian_size_64>value\n"
fb.name = append(fb.name[:0], line...)
name = bytesutil.ToUnsafeString(fb.name)
fb.value = fb.value[:0]
for len(fb.value) < 8 {
if !lr.NextLine() {
if err := lr.Err(); err != nil {
return fmt.Errorf("cannot read value size: %w", err)
}
return fmt.Errorf("unexpected end of stream while reading value size")
}
fb.value = append(fb.value, lr.Line...)
fb.value = append(fb.value, '\n')
name = bytesutil.ToUnsafeString(line)
if len(data) == 0 {
return fmt.Errorf("unexpected zero data for binary field value of key=%s", name)
}
size := binary.LittleEndian.Uint64(fb.value[:8])
for size > uint64(len(fb.value[8:])) {
if !lr.NextLine() {
if err := lr.Err(); err != nil {
return fmt.Errorf("cannot read %q value with size %d bytes; read only %d bytes: %w", fb.name, size, len(fb.value[8:]), err)
}
return fmt.Errorf("unexpected end of stream while reading %q value with size %d bytes; read only %d bytes", fb.name, size, len(fb.value[8:]))
}
fb.value = append(fb.value, lr.Line...)
fb.value = append(fb.value, '\n')
}
value = bytesutil.ToUnsafeString(fb.value[8 : len(fb.value)-1])
if uint64(len(value)) != size {
return fmt.Errorf("unexpected %q value size; got %d bytes; want %d bytes; value: %q", fb.name, len(value), size, value)
}
}
if !lr.NextLine() {
if err := lr.Err(); err != nil {
return fmt.Errorf("cannot read the next log field: %w", err)
}
// add the last log field below before the return
}
if len(name) > maxFieldNameLen {
logger.Errorf("%s: field name size should not exceed %d bytes; got %d bytes: %q; skipping this field", streamName, maxFieldNameLen, len(name), name)
continue
}
if !isValidJournaldFieldName(name) {
logger.Errorf("%s: invalid field name %q; it must consist of `A-Z0-9_` chars and must start from non-digit char; skipping this field", streamName, name)
continue
}
if slices.Contains(cp.TimeFields, name) {
t, err := strconv.ParseInt(value, 10, 64)
// size of binary data encoded as le i64 at the begging
idx, err := binary.Decode(data, binary.LittleEndian, &size)
if err != nil {
logger.Errorf("%s: cannot parse timestamp from the field %q: %w; using the current timestamp", streamName, name, err)
ts = 0
} else {
// Convert journald microsecond timestamp to nanoseconds
ts = t * 1e3
return fmt.Errorf("failed to extract binary field %q value size: %w", name, err)
}
// skip binary data size
data = data[idx:]
if size == 0 {
return fmt.Errorf("unexpected zero binary data size decoded %d", size)
}
if int(size) > len(data) {
return fmt.Errorf("binary data size=%d cannot exceed size of the data at buffer=%d", size, len(data))
}
value = bytesutil.ToUnsafeString(data[:size])
data = data[int(size):]
// binary data must has new line separator for the new line or next field
if len(data) == 0 {
return fmt.Errorf("unexpected empty buffer after binary field=%s read", name)
}
lastB := data[0]
if lastB != '\n' {
return fmt.Errorf("expected new line separator after binary field=%s, got=%s", name, string(lastB))
}
data = data[1:]
}
if len(name) > journaldEntryMaxNameLen {
return fmt.Errorf("journald entry name should not exceed %d symbols, got: %q", journaldEntryMaxNameLen, name)
}
if !allowedJournaldEntryNameChars.MatchString(name) {
return fmt.Errorf("journald entry name should consist of `A-Z0-9_` characters and must start from non-digit symbol")
}
if slices.Contains(cp.TimeFields, name) {
n, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return fmt.Errorf("failed to parse Journald timestamp, %w", err)
}
ts = n * 1e3
continue
}
@@ -335,32 +220,18 @@ func readJournaldLogEntry(streamName string, lr *insertutil.LineReader, lmp inse
name = "_msg"
}
if name == "PRIORITY" {
priority := journaldPriorityToLevel(value)
fb.addField("level", priority)
}
if !strings.HasPrefix(name, "__") || *journaldIncludeEntryMetadata {
fb.addField(name, value)
if *journaldIncludeEntryMetadata || !strings.HasPrefix(name, "__") {
fields = append(fields, logstorage.Field{
Name: name,
Value: value,
})
}
}
}
func journaldPriorityToLevel(priority string) string {
// See https://wiki.archlinux.org/title/Systemd/Journal#Priority_level
// and https://grafana.com/docs/grafana/latest/explore/logs-integration/#log-level
switch priority {
case "0", "1", "2":
return "critical"
case "3":
return "error"
case "4":
return "warning"
case "5", "6":
return "info"
case "7":
return "debug"
default:
return priority
if len(fields) > 0 {
if ts == 0 {
ts = currentTimestamp
}
lmp.AddRow(ts, fields, nil)
}
return nil
}

View File

@@ -1,81 +1,20 @@
package journald
import (
"bytes"
"net/http"
"testing"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutil"
)
func TestIsValidJournaldFieldName(t *testing.T) {
f := func(name string, resultExpected bool) {
t.Helper()
result := isValidJournaldFieldName(name)
if result != resultExpected {
t.Fatalf("unexpected result for isValidJournaldFieldName(%q); got %v; want %v", name, result, resultExpected)
}
}
f("", false)
f("a", false)
f("1", false)
f("_", true)
f("X", true)
f("Xa", false)
f("X_343", true)
f("X_0123456789_AZ", true)
f("SDDFD sdf", false)
}
func TestGetCommonParams_TimeField(t *testing.T) {
f := func(timeFieldHeader, expectedTimeField string) {
t.Helper()
req, err := http.NewRequest("POST", "/insert/journald/upload", nil)
if err != nil {
t.Fatalf("unexpected error creating request: %s", err)
}
if timeFieldHeader != "" {
req.Header.Set("VL-Time-Field", timeFieldHeader)
}
cp, err := getCommonParams(req)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if len(cp.TimeFields) != 1 || cp.TimeFields[0] != expectedTimeField {
t.Fatalf("unexpected TimeFields; got %v; want [%s]", cp.TimeFields, expectedTimeField)
}
}
// Test default behavior - when no custom time field is specified, journald uses __REALTIME_TIMESTAMP
f("", "__REALTIME_TIMESTAMP")
// Test custom time field - when a custom time field is specified via HTTP header, it's respected
f("custom_time", "custom_time")
}
func TestPushJournald_Success(t *testing.T) {
func TestPushJournaldOk(t *testing.T) {
f := func(src string, timestampsExpected []int64, resultExpected string) {
t.Helper()
tlp := &insertutil.TestLogMessageProcessor{}
r, err := http.NewRequest("GET", "https://foo.bar/baz", nil)
if err != nil {
t.Fatalf("cannot create request: %s", err)
cp := &insertutil.CommonParams{
TimeFields: []string{"__REALTIME_TIMESTAMP"},
MsgFields: []string{"MESSAGE"},
}
cp, err := getCommonParams(r)
if err != nil {
t.Fatalf("cannot create commonParams: %s", err)
}
buf := bytes.NewBufferString(src)
if err := processStreamInternal("test", buf, tlp, cp); err != nil {
if err := parseJournaldRequest([]byte(src), tlp, cp); err != nil {
t.Fatalf("unexpected error: %s", err)
}
@@ -83,17 +22,16 @@ func TestPushJournald_Success(t *testing.T) {
t.Fatal(err)
}
}
// Single event
f("__REALTIME_TIMESTAMP=91723819283\nMESSAGE=Test message\n\n",
f("__REALTIME_TIMESTAMP=91723819283\nMESSAGE=Test message\n",
[]int64{91723819283000},
"{\"_msg\":\"Test message\"}",
)
// Multiple events
f("__REALTIME_TIMESTAMP=91723819283\nPRIORITY=3\nMESSAGE=Test message\n\n__REALTIME_TIMESTAMP=91723819284\nMESSAGE=Test message2\n",
f("__REALTIME_TIMESTAMP=91723819283\nMESSAGE=Test message\n\n__REALTIME_TIMESTAMP=91723819284\nMESSAGE=Test message2\n",
[]int64{91723819283000, 91723819284000},
"{\"level\":\"error\",\"PRIORITY\":\"3\",\"_msg\":\"Test message\"}\n{\"_msg\":\"Test message2\"}",
"{\"_msg\":\"Test message\"}\n{\"_msg\":\"Test message2\"}",
)
// Parse binary data
@@ -101,45 +39,30 @@ func TestPushJournald_Success(t *testing.T) {
[]int64{1729698775704404000},
"{\"E\":\"JobStateChanged\",\"_BOOT_ID\":\"f778b6e2f7584a77b991a2366612a7b5\",\"_UID\":\"0\",\"_GID\":\"0\",\"_MACHINE_ID\":\"a4a970370c30a925df02a13c67167847\",\"_HOSTNAME\":\"ecd5e4555787\",\"_RUNTIME_SCOPE\":\"system\",\"_TRANSPORT\":\"journal\",\"_CAP_EFFECTIVE\":\"1ffffffffff\",\"_SYSTEMD_CGROUP\":\"/init.scope\",\"_SYSTEMD_UNIT\":\"init.scope\",\"_SYSTEMD_SLICE\":\"-.slice\",\"CODE_FILE\":\"\\u003cstdin>\",\"CODE_LINE\":\"1\",\"CODE_FUNC\":\"\\u003cmodule>\",\"SYSLOG_IDENTIFIER\":\"python3\",\"_COMM\":\"python3\",\"_EXE\":\"/usr/bin/python3.12\",\"_CMDLINE\":\"python3\",\"_msg\":\"foo\\nbar\\n\\n\\nasda\\nasda\",\"_PID\":\"2763\",\"_SOURCE_REALTIME_TIMESTAMP\":\"1729698775704375\"}",
)
// Empty field name must be ignored
f("__REALTIME_TIMESTAMP=91723819283\na=b\n=Test message", nil, "")
f("__REALTIME_TIMESTAMP=91723819284\nMESSAGE=Test message2\n\n__REALTIME_TIMESTAMP=91723819283\n=Test message\n", []int64{91723819284000}, `{"_msg":"Test message2"}`)
// field name starting with number must be ignored
f("__REALTIME_TIMESTAMP=91723819283\n1incorrect=Test message\n\n__REALTIME_TIMESTAMP=91723819284\nMESSAGE=Test message2\n\n", []int64{91723819284000}, `{"_msg":"Test message2"}`)
// field name exceeding 64 bytes limit must be ignored
f("__REALTIME_TIMESTAMP=91723819283\ntoolooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooongcorrecooooooooooooong=Test message\n", nil, "")
// field name with invalid chars must be ignored
f("__REALTIME_TIMESTAMP=91723819283\nbadC!@$!@$as=Test message\n", nil, "")
}
func TestPushJournald_Failure(t *testing.T) {
f := func(data string) {
t.Helper()
tlp := &insertutil.TestLogMessageProcessor{}
r, err := http.NewRequest("GET", "https://foo.bar/baz", nil)
if err != nil {
t.Fatalf("cannot create request: %s", err)
cp := &insertutil.CommonParams{
TimeFields: []string{"__REALTIME_TIMESTAMP"},
MsgFields: []string{"MESSAGE"},
}
cp, err := getCommonParams(r)
if err != nil {
t.Fatalf("cannot create commonParams: %s", err)
}
buf := bytes.NewBufferString(data)
if err := processStreamInternal("test", buf, tlp, cp); err == nil {
t.Fatalf("expecting non-nil error")
if err := parseJournaldRequest([]byte(data), tlp, cp); err == nil {
t.Fatalf("expected non nil error")
}
}
// too short binary encoded message
f("__CURSOR=s=e0afe8412a6a49d2bfcf66aa7927b588;i=1f06;b=f778b6e2f7584a77b991a2366612a7b5;m=300bdfd420;t=62526e1182354;x=930dc44b370963b7\n__REALTIME_TIMESTAMP=1729698775704404\nMESSAGE\n\x13\x00\x00\x00\x00\x00\x00\x00foo\nbar\n\n\nasdaasda")
// too long binary encoded message
f("__CURSOR=s=e0afe8412a6a49d2bfcf66aa7927b588;i=1f06;b=f778b6e2f7584a77b991a2366612a7b5;m=300bdfd420;t=62526e1182354;x=930dc44b370963b7\n__REALTIME_TIMESTAMP=1729698775704404\nMESSAGE\n\x13\x00\x00\x00\x00\x00\x00\x00foo\nbar\n\n\nasdaasdakljlsfd")
// missing new line terminator for binary encoded message
f("__CURSOR=s=e0afe8412a6a49d2bfcf66aa7927b588;i=1f06;b=f778b6e2f7584a77b991a2366612a7b5;m=300bdfd420;t=62526e1182354;x=930dc44b370963b7\n__REALTIME_TIMESTAMP=1729698775704404\nMESSAGE\n\x13\x00\x00\x00\x00\x00\x00\x00foo\nbar\n\n\nasdaasda2")
// missing new line terminator
f("__REALTIME_TIMESTAMP=91723819283\n=Test message")
// empty field name
f("__REALTIME_TIMESTAMP=91723819283\n=Test message\n")
// field name starting with number
f("__REALTIME_TIMESTAMP=91723819283\n1incorrect=Test message\n")
// field name exceeds 64 limit
f("__REALTIME_TIMESTAMP=91723819283\ntoolooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooongcorrecooooooooooooong=Test message\n")
// Only allow A-Z0-9 and '_'
f("__REALTIME_TIMESTAMP=91723819283\nbadC!@$!@$as=Test message\n")
}

View File

@@ -1,62 +0,0 @@
package journald
import (
"bytes"
"encoding/binary"
"fmt"
"testing"
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutil"
)
func generateJournaldData(size int) []byte {
var buf []byte
timestamp := time.Now().UnixMicro()
binaryMsg := []byte("binary message data for performance test")
var sizeBuf [8]byte
for len(buf) < size {
timestamp++
var entry string
// Generate a mix of simple and binary messages
if timestamp%10 == 0 {
// Generate binary message
binary.LittleEndian.PutUint64(sizeBuf[:], uint64(len(binaryMsg)))
entry = fmt.Sprintf("__REALTIME_TIMESTAMP=%d\nMESSAGE\n%s%s\n\n",
timestamp,
sizeBuf[:],
binaryMsg,
)
} else {
// Generate simple message
entry = fmt.Sprintf("__REALTIME_TIMESTAMP=%d\nMESSAGE=Performance test message %d\n\n", timestamp, timestamp)
}
buf = append(buf, entry...)
}
return buf
}
func BenchmarkPushJournaldPerformance(b *testing.B) {
cp := &insertutil.CommonParams{
TimeFields: []string{"__REALTIME_TIMESTAMP"},
MsgFields: []string{"MESSAGE"},
}
const dataChunkSize = 1024 * 1024
data := generateJournaldData(dataChunkSize)
b.ReportAllocs()
b.SetBytes(int64(len(data)))
b.RunParallel(func(pb *testing.PB) {
r := &bytes.Reader{}
blp := &insertutil.BenchmarkLogMessageProcessor{}
for pb.Next() {
r.Reset(data)
if err := processStreamInternal("performance_test", r, blp, cp); err != nil {
panic(fmt.Errorf("unexpected error: %w", err))
}
}
})
}

View File

@@ -7,6 +7,7 @@ import (
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutil"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
@@ -32,7 +33,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) {
httpserver.Errorf(w, r, "%s", err)
return
}
if err := insertutil.CanWriteData(); err != nil {
if err := vlstorage.CanWriteData(); err != nil {
httpserver.Errorf(w, r, "%s", err)
return
}

View File

@@ -16,10 +16,10 @@ var disableMessageParsing = flag.Bool("loki.disableMessageParsing", false, "Whet
// RequestHandler processes Loki insert requests
func RequestHandler(path string, w http.ResponseWriter, r *http.Request) bool {
switch path {
case "/insert/loki/api/v1/push":
case "/api/v1/push":
handleInsert(r, w)
return true
case "/insert/loki/ready":
case "/ready":
// See https://grafana.com/docs/loki/latest/api/#identify-ready-loki-instance
w.WriteHeader(http.StatusOK)
w.Write([]byte("ready"))

View File

@@ -9,6 +9,7 @@ import (
"github.com/valyala/fastjson"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutil"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/bytesutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/flagutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
@@ -29,7 +30,7 @@ func handleJSON(r *http.Request, w http.ResponseWriter) {
httpserver.Errorf(w, r, "cannot parse common params from request: %s", err)
return
}
if err := insertutil.CanWriteData(); err != nil {
if err := vlstorage.CanWriteData(); err != nil {
httpserver.Errorf(w, r, "%s", err)
return
}

View File

@@ -9,6 +9,7 @@ import (
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutil"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/protoparserutil"
@@ -28,7 +29,7 @@ func handleProtobuf(r *http.Request, w http.ResponseWriter) {
httpserver.Errorf(w, r, "cannot parse common params from request: %s", err)
return
}
if err := insertutil.CanWriteData(); err != nil {
if err := vlstorage.CanWriteData(); err != nil {
httpserver.Errorf(w, r, "%s", err)
return
}

View File

@@ -1,7 +1,6 @@
package vlinsert
import (
"flag"
"fmt"
"net/http"
"strings"
@@ -14,12 +13,6 @@ import (
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/loki"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/opentelemetry"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/syslog"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
)
var (
disableInsert = flag.Bool("insert.disable", false, "Whether to disable /insert/* HTTP endpoints")
disableInternal = flag.Bool("internalinsert.disable", false, "Whether to disable /internal/insert HTTP endpoint")
)
// Init initializes vlinsert
@@ -34,55 +27,49 @@ func Stop() {
// RequestHandler handles insert requests for VictoriaLogs
func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
path := strings.ReplaceAll(r.URL.Path, "//", "/")
if strings.HasPrefix(path, "/insert/") {
if *disableInsert {
httpserver.Errorf(w, r, "requests to /insert/* are disabled with -insert.disable command-line flag")
return true
}
return insertHandler(w, r, path)
}
path := r.URL.Path
if path == "/internal/insert" {
if *disableInternal || *disableInsert {
httpserver.Errorf(w, r, "requests to /internal/insert are disabled with -internalinsert.disable or -insert.disable command-line flag")
return true
}
internalinsert.RequestHandler(w, r)
return true
}
return false
}
if !strings.HasPrefix(path, "/insert/") {
// Skip requests, which do not start with /insert/, since these aren't our requests.
return false
}
path = strings.TrimPrefix(path, "/insert")
path = strings.ReplaceAll(path, "//", "/")
func insertHandler(w http.ResponseWriter, r *http.Request, path string) bool {
switch path {
case "/insert/jsonline":
case "/jsonline":
jsonline.RequestHandler(w, r)
return true
case "/insert/ready":
case "/ready":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
fmt.Fprintf(w, `{"status":"ok"}`)
return true
}
switch {
// some clients may omit trailing slash at elasticsearch protocol.
// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/8353
case strings.HasPrefix(path, "/insert/elasticsearch"):
case strings.HasPrefix(path, "/elasticsearch"):
// some clients may omit trailing slash
// see https://github.com/VictoriaMetrics/VictoriaMetrics/issues/8353
path = strings.TrimPrefix(path, "/elasticsearch")
return elasticsearch.RequestHandler(path, w, r)
case strings.HasPrefix(path, "/insert/loki/"):
case strings.HasPrefix(path, "/loki/"):
path = strings.TrimPrefix(path, "/loki")
return loki.RequestHandler(path, w, r)
case strings.HasPrefix(path, "/insert/opentelemetry/"):
case strings.HasPrefix(path, "/opentelemetry/"):
path = strings.TrimPrefix(path, "/opentelemetry")
return opentelemetry.RequestHandler(path, w, r)
case strings.HasPrefix(path, "/insert/journald/"):
case strings.HasPrefix(path, "/journald/"):
path = strings.TrimPrefix(path, "/journald")
return journald.RequestHandler(path, w, r)
case strings.HasPrefix(path, "/insert/datadog/"):
case strings.HasPrefix(path, "/datadog/"):
path = strings.TrimPrefix(path, "/datadog")
return datadog.RequestHandler(path, w, r)
default:
return false
}
return false
}

View File

@@ -6,6 +6,7 @@ import (
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutil"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/flagutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
@@ -21,7 +22,7 @@ func RequestHandler(path string, w http.ResponseWriter, r *http.Request) bool {
switch path {
// use the same path as opentelemetry collector
// https://opentelemetry.io/docs/specs/otlp/#otlphttp-request
case "/insert/opentelemetry/v1/logs":
case "/v1/logs":
if r.Header.Get("Content-Type") == "application/json" {
httpserver.Errorf(w, r, "json encoding isn't supported for opentelemetry format. Use protobuf encoding")
return true
@@ -42,7 +43,7 @@ func handleProtobuf(r *http.Request, w http.ResponseWriter) {
httpserver.Errorf(w, r, "cannot parse common params from request: %s", err)
return
}
if err := insertutil.CanWriteData(); err != nil {
if err := vlstorage.CanWriteData(); err != nil {
httpserver.Errorf(w, r, "%s", err)
return
}

View File

@@ -17,6 +17,7 @@ import (
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutil"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/bytesutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/cgroup"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/flagutil"
@@ -384,7 +385,7 @@ func serveTCP(ln net.Listener, tenantID logstorage.TenantID, encoding string, us
// processStream parses a stream of syslog messages from r and ingests them into vlstorage.
func processStream(protocol string, r io.Reader, encoding string, useLocalTimestamp bool, cp *insertutil.CommonParams) error {
if err := insertutil.CanWriteData(); err != nil {
if err := vlstorage.CanWriteData(); err != nil {
return err
}

View File

@@ -101,8 +101,8 @@ func TestProcessStreamInternal_Success(t *testing.T) {
currentYear := 2023
timestampsExpected := []int64{1685794113000000000, 1685880513000000000, 1685814132345000000}
resultExpected := `{"format":"rfc3164","hostname":"abcd","app_name":"systemd","_msg":"Starting Update the local ESM caches..."}
{"priority":"165","facility_keyword":"local4","level":"info","facility":"20","severity":"5","format":"rfc3164","hostname":"abcd","app_name":"systemd","proc_id":"345","_msg":"abc defg"}
{"priority":"123","facility_keyword":"solaris-cron","level":"error","facility":"15","severity":"3","format":"rfc5424","hostname":"mymachine.example.com","app_name":"appname","proc_id":"12345","msg_id":"ID47","exampleSDID@32473.iut":"3","exampleSDID@32473.eventSource":"Application 123 = ] 56","exampleSDID@32473.eventID":"11211","_msg":"This is a test message with structured data."}`
{"priority":"165","facility":"20","severity":"5","format":"rfc3164","hostname":"abcd","app_name":"systemd","proc_id":"345","_msg":"abc defg"}
{"priority":"123","facility":"15","severity":"3","format":"rfc5424","hostname":"mymachine.example.com","app_name":"appname","proc_id":"12345","msg_id":"ID47","exampleSDID@32473.iut":"3","exampleSDID@32473.eventSource":"Application 123 = ] 56","exampleSDID@32473.eventID":"11211","_msg":"This is a test message with structured data."}`
f(data, currentYear, timestampsExpected, resultExpected)
}

View File

@@ -2,6 +2,7 @@ package internalselect
import (
"context"
"flag"
"fmt"
"net/http"
"strconv"
@@ -21,8 +22,15 @@ import (
"github.com/VictoriaMetrics/VictoriaMetrics/lib/netutil"
)
var disableSelect = flag.Bool("internalselect.disable", false, "Whether to disable /internal/select/* HTTP endpoints")
// RequestHandler processes requests to /internal/select/*
func RequestHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
if *disableSelect {
httpserver.Errorf(w, r, "requests to /internal/select/* are disabled with -internalselect.disable command-line flag")
return
}
startTime := time.Now()
path := r.URL.Path

View File

@@ -55,10 +55,7 @@ func ProcessFacetsRequest(ctx context.Context, w http.ResponseWriter, r *http.Re
}
keepConstFields := httputil.GetBool(r, "keep_const_fields")
// Pipes must be dropped, since it is expected facets are obtained
// from the real logs stored in the database.
q.DropAllPipes()
q.AddFacetsPipe(limit, maxValuesPerField, maxValueLen, keepConstFields)
var mLock sync.Mutex
@@ -159,10 +156,8 @@ func ProcessHitsRequest(ctx context.Context, w http.ResponseWriter, r *http.Requ
fieldsLimit = 0
}
// Pipes must be dropped, since it is expected hits are obtained
// from the real logs stored in the database.
// Prepare the query for hits count.
q.DropAllPipes()
q.AddCountByTimePipe(int64(step), int64(offset), fields)
var mLock sync.Mutex
@@ -295,10 +290,6 @@ func ProcessFieldNamesRequest(ctx context.Context, w http.ResponseWriter, r *htt
return
}
// Pipes must be dropped, since it is expected field names are obtained
// from the real logs stored in the database.
q.DropAllPipes()
// Obtain field names for the given query
fieldNames, err := vlstorage.GetFieldNames(ctx, tenantIDs, q)
if err != nil {
@@ -338,10 +329,6 @@ func ProcessFieldValuesRequest(ctx context.Context, w http.ResponseWriter, r *ht
limit = 0
}
// Pipes must be dropped, since it is expected field values are obtained
// from the real logs stored in the database.
q.DropAllPipes()
// Obtain unique values for the given field
values, err := vlstorage.GetFieldValues(ctx, tenantIDs, q, fieldName, uint64(limit))
if err != nil {
@@ -364,10 +351,6 @@ func ProcessStreamFieldNamesRequest(ctx context.Context, w http.ResponseWriter,
return
}
// Pipes must be dropped, since it is expected stream field names are obtained
// from the real logs stored in the database.
q.DropAllPipes()
// Obtain stream field names for the given query
names, err := vlstorage.GetStreamFieldNames(ctx, tenantIDs, q)
if err != nil {
@@ -406,10 +389,6 @@ func ProcessStreamFieldValuesRequest(ctx context.Context, w http.ResponseWriter,
limit = 0
}
// Pipes must be dropped, since it is expected stream field values are obtained
// from the real logs stored in the database.
q.DropAllPipes()
// Obtain stream field values for the given query and the given fieldName
values, err := vlstorage.GetStreamFieldValues(ctx, tenantIDs, q, fieldName, uint64(limit))
if err != nil {
@@ -441,10 +420,6 @@ func ProcessStreamIDsRequest(ctx context.Context, w http.ResponseWriter, r *http
limit = 0
}
// Pipes must be dropped, since it is expected stream ids are obtained
// from the real logs stored in the database.
q.DropAllPipes()
// Obtain streamIDs for the given query
streamIDs, err := vlstorage.GetStreamIDs(ctx, tenantIDs, q, uint64(limit))
if err != nil {
@@ -476,10 +451,6 @@ func ProcessStreamsRequest(ctx context.Context, w http.ResponseWriter, r *http.R
limit = 0
}
// Pipes must be dropped, since it is expected stream are obtained
// from the real logs stored in the database.
q.DropAllPipes()
// Obtain streams for the given query
streams, err := vlstorage.GetStreams(ctx, tenantIDs, q, uint64(limit))
if err != nil {
@@ -580,7 +551,7 @@ var liveTailRequests = metrics.NewCounter(`vl_live_tailing_requests`)
const tailOffsetNsecs = 5e9
type logRow struct {
timestamp int64
timestamp string
fields []logstorage.Field
}
@@ -596,7 +567,7 @@ type tailProcessor struct {
mu sync.Mutex
perStreamRows map[string][]logRow
lastTimestamps map[string]int64
lastTimestamps map[string]string
err error
}
@@ -606,7 +577,7 @@ func newTailProcessor(cancel func()) *tailProcessor {
cancel: cancel,
perStreamRows: make(map[string][]logRow),
lastTimestamps: make(map[string]int64),
lastTimestamps: make(map[string]string),
}
}
@@ -623,7 +594,7 @@ func (tp *tailProcessor) writeBlock(_ uint, db *logstorage.DataBlock) {
}
// Make sure columns contain _time field, since it is needed for proper tail work.
timestamps, ok := db.GetTimestamps(nil)
timestamps, ok := db.GetTimestamps()
if !ok {
tp.err = fmt.Errorf("missing _time field")
tp.cancel()
@@ -1072,7 +1043,9 @@ func getLastNQueryResults(ctx context.Context, tenantIDs []logstorage.TenantID,
}
func getLastNRows(rows []logRow, limit int) []logRow {
sortLogRows(rows)
sort.Slice(rows, func(i, j int) bool {
return rows[i].timestamp < rows[j].timestamp
})
if len(rows) > limit {
rows = rows[len(rows)-limit:]
}
@@ -1097,7 +1070,7 @@ func getQueryResultsWithLimit(ctx context.Context, tenantIDs []logstorage.Tenant
clonedColumnNames[i] = strings.Clone(c.Name)
}
timestamps, ok := db.GetTimestamps(nil)
timestamps, ok := db.GetTimestamps()
if !ok {
missingTimeColumn.Store(true)
cancel()

View File

@@ -25,9 +25,6 @@ var (
maxQueueDuration = flag.Duration("search.maxQueueDuration", 10*time.Second, "The maximum time the search request waits for execution when -search.maxConcurrentRequests "+
"limit is reached; see also -search.maxQueryDuration")
maxQueryDuration = flag.Duration("search.maxQueryDuration", time.Second*30, "The maximum duration for query execution. It can be overridden to a smaller value on a per-query basis via 'timeout' query arg")
disableSelect = flag.Bool("select.disable", false, "Whether to disable /select/* HTTP endpoints")
disableInternal = flag.Bool("internalselect.disable", false, "Whether to disable /internal/select/* HTTP endpoints")
)
func getDefaultMaxConcurrentRequests() int {
@@ -74,31 +71,13 @@ var vmuiFileServer = http.FileServer(http.FS(vmuiFiles))
// RequestHandler handles select requests for VictoriaLogs
func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
path := strings.ReplaceAll(r.URL.Path, "//", "/")
path := r.URL.Path
if strings.HasPrefix(path, "/select/") {
if *disableSelect {
httpserver.Errorf(w, r, "requests to /select/* are disabled with -select.disable command-line flag")
return true
}
return selectHandler(w, r, path)
if !strings.HasPrefix(path, "/select/") && !strings.HasPrefix(path, "/internal/select/") {
// Skip requests, which do not start with /select/, since these aren't our requests.
return false
}
if strings.HasPrefix(path, "/internal/select/") {
if *disableInternal || *disableSelect {
httpserver.Errorf(w, r, "requests to /internal/select/* are disabled with -internalselect.disable or -select.disable command-line flag")
return true
}
internalselect.RequestHandler(r.Context(), w, r)
return true
}
return false
}
func selectHandler(w http.ResponseWriter, r *http.Request, path string) bool {
ctx := r.Context()
path = strings.ReplaceAll(path, "//", "/")
if path == "/select/vmui" {
// VMUI access via incomplete url without `/` in the end. Redirect to complete url.
@@ -121,6 +100,7 @@ func selectHandler(w http.ResponseWriter, r *http.Request, path string) bool {
return true
}
ctx := r.Context()
if path == "/select/logsql/tail" {
logsqlTailRequests.Inc()
// Process live tailing request without timeout, since it is OK to run live tailing requests for very long time.
@@ -140,6 +120,13 @@ func selectHandler(w http.ResponseWriter, r *http.Request, path string) bool {
}
defer decRequestConcurrency()
if strings.HasPrefix(path, "/internal/select/") {
// Process internal request from vlselect without timeout (e.g. use ctx instead of ctxWithTimeout),
// since the timeout must be controlled by the vlselect.
internalselect.RequestHandler(ctx, w, r)
return true
}
ok := processSelectRequest(ctxWithTimeout, w, r, path)
if !ok {
return false

View File

@@ -66,8 +66,8 @@ or at your own [VictoriaMetrics instance](https://docs.victoriametrics.com/victo
The list of MetricsQL features on top of PromQL:
* Graphite-compatible filters can be passed via `{__graphite__="foo.*.bar"}` syntax.
See [these docs](https://docs.victoriametrics.com/victoriametrics/integrations/graphite/#selecting-graphite-metrics).
VictoriaMetrics can be used as Graphite datasource in Grafana. See [these docs](https://docs.victoriametrics.com/victoriametrics/integrations/graphite/#graphite-api-usage) for details.
See [these docs](https://docs.victoriametrics.com/victoriametrics/integrations/graphite#selecting-graphite-metrics).
VictoriaMetrics can be used as Graphite datasource in Grafana. See [these docs](https://docs.victoriametrics.com/victoriametrics/integrations/graphite#graphite-api-usage) for details.
See also [label_graphite_group](#label_graphite_group) function, which can be used for extracting the given groups from Graphite metric name.
* Lookbehind window in square brackets for [rollup functions](#rollup-functions) may be omitted. VictoriaMetrics automatically selects the lookbehind window
depending on the `step` query arg passed to [/api/v1/query_range](https://docs.victoriametrics.com/victoriametrics/keyconcepts/#range-query)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -35,10 +35,10 @@
<meta property="og:title" content="UI for VictoriaLogs">
<meta property="og:url" content="https://victoriametrics.com/products/victorialogs/">
<meta property="og:description" content="Explore your log data with VictoriaLogs UI">
<script type="module" crossorigin src="./assets/index-DhqzKCNf.js"></script>
<script type="module" crossorigin src="./assets/index-DLp5TlUn.js"></script>
<link rel="modulepreload" crossorigin href="./assets/vendor-D8IJGiEn.js">
<link rel="stylesheet" crossorigin href="./assets/vendor-D1GxaB_c.css">
<link rel="stylesheet" crossorigin href="./assets/index-D5re9hC6.css">
<link rel="stylesheet" crossorigin href="./assets/index-C85_NB5q.css">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@@ -253,11 +253,8 @@ func processForceFlush(w http.ResponseWriter, r *http.Request) bool {
return true
}
// Storage implements insertutil.LogRowsStorage interface
type Storage struct{}
// CanWriteData returns non-nil error if it cannot write data to vlstorage
func (*Storage) CanWriteData() error {
func CanWriteData() error {
if localStorage == nil {
// The data can be always written in non-local mode.
return nil
@@ -276,7 +273,7 @@ func (*Storage) CanWriteData() error {
// MustAddRows adds lr to vlstorage
//
// It is advised to call CanWriteData() before calling MustAddRows()
func (*Storage) MustAddRows(lr *logstorage.LogRows) {
func MustAddRows(lr *logstorage.LogRows) {
if localStorage != nil {
// Store lr in the local storage.
localStorage.MustAddRows(lr)

View File

@@ -248,9 +248,6 @@ func (sn *storageNode) executeRequestAt(ctx context.Context, path string, args u
if err != nil {
logger.Panicf("BUG: unexpected error when creating a request: %s", err)
}
if err := sn.ac.SetHeaders(req, true); err != nil {
return nil, fmt.Errorf("cannot set auth headers for %q: %w", reqURL, err)
}
// send the request to the storage node
resp, err := sn.c.Do(req)

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"hash/fnv"
"math"
"sort"
"strings"
"sync"
@@ -336,9 +335,7 @@ func (ar *AlertingRule) execRange(ctx context.Context, start, end time.Time) ([]
var result []prompbmarshal.TimeSeries
holdAlertState := make(map[uint64]*notifier.Alert)
qFn := func(_ string) ([]datasource.Metric, error) {
logger.Warnf("`query` template isn't supported in replay mode, mocked data is used")
// mock query results to allow common used template {{ query <$expr> | first | value }}
return []datasource.Metric{{Timestamps: []int64{0}, Values: []float64{math.NaN()}}}, nil
return nil, fmt.Errorf("`query` template isn't supported in replay mode")
}
for _, s := range res.Data {
ls, as, err := ar.expandTemplates(s, qFn, time.Time{})
@@ -416,7 +413,7 @@ func (ar *AlertingRule) exec(ctx context.Context, ts time.Time, limit int) ([]pr
return nil, fmt.Errorf("failed to execute query %q: %w", ar.Expr, err)
}
ar.logDebugf(ts, nil, "query returned %d series (elapsed: %s, isPartial: %t)", curState.Samples, curState.Duration, isPartialResponse(res))
ar.logDebugf(ts, nil, "query returned %d samples (elapsed: %s, isPartial: %t)", curState.Samples, curState.Duration, isPartialResponse(res))
qFn := func(query string) ([]datasource.Metric, error) {
res, _, err := ar.q.Query(ctx, query, ts)
return res.Data, err

View File

@@ -162,7 +162,7 @@
<thead>
<tr>
<th scope="col" style="width: 60%">Rule</th>
<th scope="col" style="width: 20%" class="text-center" title="How many series were produced by the rule">Series</th>
<th scope="col" style="width: 20%" class="text-center" title="How many samples were produced by the rule">Samples</th>
<th scope="col" style="width: 20%" class="text-center" title="How many seconds ago rule was executed">Updated</th>
</tr>
</thead>
@@ -594,7 +594,7 @@
<thead>
<tr>
<th scope="col" title="The time when event was created">Updated at</th>
<th scope="col" style="width: 10%" class="text-center" title="How many series expression returns. Each series will represent an alert.">Series returned</th>
<th scope="col" style="width: 10%" class="text-center" title="How many samples were returned">Samples</th>
{% if seriesFetchedEnabled %}<th scope="col" style="width: 10%" class="text-center" title="How many series were scanned by datasource during the evaluation">Series fetched</th>{% endif %}
<th scope="col" style="width: 10%" class="text-center" title="How many seconds request took">Duration</th>
<th scope="col" class="text-center" title="Time used for rule execution">Executed at</th>

View File

@@ -524,7 +524,7 @@ func StreamListGroups(qw422016 *qt422016.Writer, r *http.Request, groups []apiGr
<thead>
<tr>
<th scope="col" style="width: 60%">Rule</th>
<th scope="col" style="width: 20%" class="text-center" title="How many series were produced by the rule">Series</th>
<th scope="col" style="width: 20%" class="text-center" title="How many samples were produced by the rule">Samples</th>
<th scope="col" style="width: 20%" class="text-center" title="How many seconds ago rule was executed">Updated</th>
</tr>
</thead>
@@ -1697,7 +1697,7 @@ func StreamRuleDetails(qw422016 *qt422016.Writer, r *http.Request, rule apiRule)
<thead>
<tr>
<th scope="col" title="The time when event was created">Updated at</th>
<th scope="col" style="width: 10%" class="text-center" title="How many series expression returns. Each series will represent an alert.">Series returned</th>
<th scope="col" style="width: 10%" class="text-center" title="How many samples were returned">Samples</th>
`)
//line app/vmalert/web.qtpl:598
if seriesFetchedEnabled {

View File

@@ -120,9 +120,6 @@ func normalizeURL(uOrig *url.URL) *url.URL {
u := *uOrig
// Prevent from attacks with using `..` in r.URL.Path
u.Path = path.Clean(u.Path)
if u.Path == "." {
u.Path = "/"
}
if !strings.HasSuffix(u.Path, "/") && strings.HasSuffix(uOrig.Path, "/") {
// The path.Clean() removes trailing slash.
// Return it back if needed.

View File

@@ -128,40 +128,7 @@ func TestCreateTargetURLSuccess(t *testing.T) {
// Simple routing with `url_prefix`
f(&UserInfo{
URLPrefix: mustParseURL("http://foo.bar"),
}, "", "http://foo.bar", "", "", nil, "least_loaded", 0)
f(&UserInfo{
URLPrefix: mustParseURL("http://foo.bar"),
}, "/", "http://foo.bar", "", "", nil, "least_loaded", 0)
f(&UserInfo{
URLPrefix: mustParseURL("http://foo.bar"),
}, "http://aaa///", "http://foo.bar", "", "", nil, "least_loaded", 0)
f(&UserInfo{
URLPrefix: mustParseURL("http://foo.bar/"),
}, "/", "http://foo.bar/", "", "", nil, "least_loaded", 0)
f(&UserInfo{
URLPrefix: mustParseURL("http://foo.bar/"),
}, "/x", "http://foo.bar/x", "", "", nil, "least_loaded", 0)
f(&UserInfo{
URLPrefix: mustParseURL("http://foo.bar/"),
}, "/x/", "http://foo.bar/x/", "", "", nil, "least_loaded", 0)
f(&UserInfo{
URLPrefix: mustParseURL("http://foo.bar/"),
}, "http://abc///x/", "http://foo.bar/x/", "", "", nil, "least_loaded", 0)
f(&UserInfo{
URLPrefix: mustParseURL("http://foo.bar/"),
}, "http://foo//x", "http://foo.bar/x", "", "", nil, "least_loaded", 0)
f(&UserInfo{
URLPrefix: mustParseURL("http://foo.bar/baz"),
}, "", "http://foo.bar/baz", "", "", nil, "least_loaded", 0)
f(&UserInfo{
URLPrefix: mustParseURL("http://foo.bar/baz"),
}, "/", "http://foo.bar/baz", "", "", nil, "least_loaded", 0)
f(&UserInfo{
URLPrefix: mustParseURL("http://foo.bar/x/"),
}, "/abc", "http://foo.bar/x/abc", "", "", nil, "least_loaded", 0)
f(&UserInfo{
URLPrefix: mustParseURL("http://foo.bar/x/"),
}, "/abc/", "http://foo.bar/x/abc/", "", "", nil, "least_loaded", 0)
}, "", "http://foo.bar/.", "", "", nil, "least_loaded", 0)
f(&UserInfo{
URLPrefix: mustParseURL("http://foo.bar"),
HeadersConf: HeadersConf{
@@ -182,12 +149,6 @@ func TestCreateTargetURLSuccess(t *testing.T) {
f(&UserInfo{
URLPrefix: mustParseURL("http://foo.bar"),
}, "a/b?c=d", "http://foo.bar/a/b?c=d", "", "", nil, "least_loaded", 0)
f(&UserInfo{
URLPrefix: mustParseURL("http://foo.bar"),
}, "/a/b?c=d", "http://foo.bar/a/b?c=d", "", "", nil, "least_loaded", 0)
f(&UserInfo{
URLPrefix: mustParseURL("http://foo.bar/"),
}, "/a/b?c=d", "http://foo.bar/a/b?c=d", "", "", nil, "least_loaded", 0)
f(&UserInfo{
URLPrefix: mustParseURL("https://sss:3894/x/y"),
}, "/z", "https://sss:3894/x/y/z", "", "", nil, "least_loaded", 0)

View File

@@ -19,7 +19,6 @@ import (
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/barpool"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/native"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/remoteread"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/netutil"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/influx"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/opentsdb"
@@ -45,7 +44,6 @@ func main() {
if c.Bool(globalDisableProgressBar) {
barpool.Disable(true)
}
netutil.EnableIPv6()
return nil
}
app := &cli.App{

View File

@@ -0,0 +1,351 @@
package main
import (
"context"
"testing"
"time"
"github.com/prometheus/prometheus/prompb"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/backoff"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/barpool"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/remoteread"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/stepper"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/testdata/servers_integration_test"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/vm"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httputil"
)
func TestRemoteRead(t *testing.T) {
barpool.Disable(true)
defer func() {
barpool.Disable(false)
}()
defer func() { isSilent = false }()
var testCases = []struct {
name string
remoteReadConfig remoteread.Config
vmCfg vm.Config
start string
end string
numOfSamples int64
numOfSeries int64
rrp remoteReadProcessor
chunk string
remoteReadSeries func(start, end, numOfSeries, numOfSamples int64) []*prompb.TimeSeries
expectedSeries []vm.TimeSeries
}{
{
name: "step minute on minute time range",
remoteReadConfig: remoteread.Config{Addr: "", LabelName: "__name__", LabelValue: ".*"},
vmCfg: vm.Config{Addr: "", Concurrency: 1},
start: "2022-11-26T11:23:05+02:00",
end: "2022-11-26T11:24:05+02:00",
numOfSamples: 2,
numOfSeries: 3,
chunk: stepper.StepMinute,
remoteReadSeries: remote_read_integration.GenerateRemoteReadSeries,
expectedSeries: []vm.TimeSeries{
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "0"}},
Timestamps: []int64{1669454585000, 1669454615000},
Values: []float64{0, 0},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "1"}},
Timestamps: []int64{1669454585000, 1669454615000},
Values: []float64{100, 100},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "2"}},
Timestamps: []int64{1669454585000, 1669454615000},
Values: []float64{200, 200},
},
},
},
{
name: "step month on month time range",
remoteReadConfig: remoteread.Config{Addr: "", LabelName: "__name__", LabelValue: ".*"},
vmCfg: vm.Config{
Addr: "",
Concurrency: 1,
Transport: httputil.NewTransport(false, "vmctl_test_read"),
},
start: "2022-09-26T11:23:05+02:00",
end: "2022-11-26T11:24:05+02:00",
numOfSamples: 2,
numOfSeries: 3,
chunk: stepper.StepMonth,
remoteReadSeries: remote_read_integration.GenerateRemoteReadSeries,
expectedSeries: []vm.TimeSeries{
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "0"}},
Timestamps: []int64{1664184185000},
Values: []float64{0},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "1"}},
Timestamps: []int64{1664184185000},
Values: []float64{100},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "2"}},
Timestamps: []int64{1664184185000},
Values: []float64{200},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "0"}},
Timestamps: []int64{1666819415000},
Values: []float64{0},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "1"}},
Timestamps: []int64{1666819415000},
Values: []float64{100},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "2"}},
Timestamps: []int64{1666819415000},
Values: []float64{200}},
},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
remoteReadServer := remote_read_integration.NewRemoteReadServer(t)
defer remoteReadServer.Close()
remoteWriteServer := remote_read_integration.NewRemoteWriteServer(t)
defer remoteWriteServer.Close()
tt.remoteReadConfig.Addr = remoteReadServer.URL()
rr, err := remoteread.NewClient(tt.remoteReadConfig)
if err != nil {
t.Fatalf("error create remote read client: %s", err)
}
start, err := time.Parse(time.RFC3339, tt.start)
if err != nil {
t.Fatalf("Error parse start time: %s", err)
}
end, err := time.Parse(time.RFC3339, tt.end)
if err != nil {
t.Fatalf("Error parse end time: %s", err)
}
rrs := tt.remoteReadSeries(start.Unix(), end.Unix(), tt.numOfSeries, tt.numOfSamples)
remoteReadServer.SetRemoteReadSeries(rrs)
remoteWriteServer.ExpectedSeries(tt.expectedSeries)
tt.vmCfg.Addr = remoteWriteServer.URL()
b, err := backoff.New(10, 1.8, time.Second*2)
if err != nil {
t.Fatalf("failed to create backoff: %s", err)
}
tt.vmCfg.Backoff = b
importer, err := vm.NewImporter(ctx, tt.vmCfg)
if err != nil {
t.Fatalf("failed to create VM importer: %s", err)
}
defer importer.Close()
rmp := remoteReadProcessor{
src: rr,
dst: importer,
filter: remoteReadFilter{
timeStart: &start,
timeEnd: &end,
chunk: tt.chunk,
},
cc: 1,
isVerbose: false,
}
err = rmp.run(ctx)
if err != nil {
t.Fatalf("failed to run remote read processor: %s", err)
}
})
}
}
func TestSteamRemoteRead(t *testing.T) {
barpool.Disable(true)
defer func() {
barpool.Disable(false)
}()
defer func() { isSilent = false }()
var testCases = []struct {
name string
remoteReadConfig remoteread.Config
vmCfg vm.Config
start string
end string
numOfSamples int64
numOfSeries int64
rrp remoteReadProcessor
chunk string
remoteReadSeries func(start, end, numOfSeries, numOfSamples int64) []*prompb.TimeSeries
expectedSeries []vm.TimeSeries
}{
{
name: "step minute on minute time range",
remoteReadConfig: remoteread.Config{Addr: "", LabelName: "__name__", LabelValue: ".*", UseStream: true},
vmCfg: vm.Config{Addr: "", Concurrency: 1},
start: "2022-11-26T11:23:05+02:00",
end: "2022-11-26T11:24:05+02:00",
numOfSamples: 2,
numOfSeries: 3,
chunk: stepper.StepMinute,
remoteReadSeries: remote_read_integration.GenerateRemoteReadSeries,
expectedSeries: []vm.TimeSeries{
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "0"}},
Timestamps: []int64{1669454585000, 1669454615000},
Values: []float64{0, 0},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "1"}},
Timestamps: []int64{1669454585000, 1669454615000},
Values: []float64{100, 100},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "2"}},
Timestamps: []int64{1669454585000, 1669454615000},
Values: []float64{200, 200},
},
},
},
{
name: "step month on month time range",
remoteReadConfig: remoteread.Config{Addr: "", LabelName: "__name__", LabelValue: ".*", UseStream: true},
vmCfg: vm.Config{Addr: "", Concurrency: 1},
start: "2022-09-26T11:23:05+02:00",
end: "2022-11-26T11:24:05+02:00",
numOfSamples: 2,
numOfSeries: 3,
chunk: stepper.StepMonth,
remoteReadSeries: remote_read_integration.GenerateRemoteReadSeries,
expectedSeries: []vm.TimeSeries{
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "0"}},
Timestamps: []int64{1664184185000},
Values: []float64{0},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "1"}},
Timestamps: []int64{1664184185000},
Values: []float64{100},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "2"}},
Timestamps: []int64{1664184185000},
Values: []float64{200},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "0"}},
Timestamps: []int64{1666819415000},
Values: []float64{0},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "1"}},
Timestamps: []int64{1666819415000},
Values: []float64{100},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "2"}},
Timestamps: []int64{1666819415000},
Values: []float64{200}},
},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
remoteReadServer := remote_read_integration.NewRemoteReadStreamServer(t)
defer remoteReadServer.Close()
remoteWriteServer := remote_read_integration.NewRemoteWriteServer(t)
defer remoteWriteServer.Close()
tt.remoteReadConfig.Addr = remoteReadServer.URL()
rr, err := remoteread.NewClient(tt.remoteReadConfig)
if err != nil {
t.Fatalf("error create remote read client: %s", err)
}
start, err := time.Parse(time.RFC3339, tt.start)
if err != nil {
t.Fatalf("Error parse start time: %s", err)
}
end, err := time.Parse(time.RFC3339, tt.end)
if err != nil {
t.Fatalf("Error parse end time: %s", err)
}
rrs := tt.remoteReadSeries(start.Unix(), end.Unix(), tt.numOfSeries, tt.numOfSamples)
remoteReadServer.InitMockStorage(rrs)
remoteWriteServer.ExpectedSeries(tt.expectedSeries)
tt.vmCfg.Addr = remoteWriteServer.URL()
b, err := backoff.New(10, 1.8, time.Second*2)
if err != nil {
t.Fatalf("failed to create backoff: %s", err)
}
tt.vmCfg.Backoff = b
importer, err := vm.NewImporter(ctx, tt.vmCfg)
if err != nil {
t.Fatalf("failed to create VM importer: %s", err)
}
defer importer.Close()
rmp := remoteReadProcessor{
src: rr,
dst: importer,
filter: remoteReadFilter{
timeStart: &start,
timeEnd: &end,
chunk: tt.chunk,
},
cc: 1,
isVerbose: false,
}
err = rmp.run(ctx)
if err != nil {
t.Fatalf("failed to run remote read processor: %s", err)
}
})
}
}

View File

@@ -1,4 +1,4 @@
package tests
package remote_read_integration
import (
"context"
@@ -12,29 +12,32 @@ 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"
"github.com/prometheus/prometheus/storage/remote"
"github.com/prometheus/prometheus/tsdb/chunkenc"
"github.com/prometheus/prometheus/tsdb/chunks"
"github.com/prometheus/prometheus/util/annotations"
)
const (
maxBytesInFrame = 1024 * 1024
)
// RemoteReadServer is a mock server that implements the Prometheus remote read protocol.
type RemoteReadServer struct {
server *httptest.Server
storage *PrometheusMockStorage
series []*prompb.TimeSeries
storage *MockStorage
}
// NewRemoteReadServer creates a remote read server. It exposes a single endpoint and responds with the
// passed series based on the request to the read endpoint. It returns a server which should be closed after
// being used.
func NewRemoteReadServer(t *testing.T, series []*prompb.TimeSeries) *RemoteReadServer {
mockStorage := NewPrometheusMockStorage(series)
func NewRemoteReadServer(t *testing.T) *RemoteReadServer {
rrs := &RemoteReadServer{
storage: mockStorage,
series: make([]*prompb.TimeSeries, 0),
}
rrs.server = httptest.NewServer(rrs.getReadHandler(t))
return rrs
@@ -45,11 +48,14 @@ func (rrs *RemoteReadServer) Close() {
rrs.server.Close()
}
// HTTPAddr returns the HTTP address of the server.
func (rrs *RemoteReadServer) HTTPAddr() string {
func (rrs *RemoteReadServer) URL() string {
return rrs.server.URL
}
func (rrs *RemoteReadServer) SetRemoteReadSeries(series []*prompb.TimeSeries) {
rrs.series = append(rrs.series, series...)
}
func (rrs *RemoteReadServer) getReadHandler(t *testing.T) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !validateReadHeaders(t, r) {
@@ -78,8 +84,8 @@ func (rrs *RemoteReadServer) getReadHandler(t *testing.T) http.Handler {
for i, r := range req.Queries {
startTs := r.StartTimestampMs
endTs := r.EndTimestampMs
ts := make([]*prompb.TimeSeries, len(rrs.storage.store))
for i, s := range rrs.storage.store {
ts := make([]*prompb.TimeSeries, len(rrs.series))
for i, s := range rrs.series {
var samples []prompb.Sample
for _, sample := range s.Samples {
if sample.Timestamp >= startTs && sample.Timestamp < endTs {
@@ -113,18 +119,18 @@ func (rrs *RemoteReadServer) getReadHandler(t *testing.T) http.Handler {
})
}
// NewRemoteReadStreamServer creates a remote read server that supports streaming responses.
// passed series based on the request to the read endpoint. It returns a server which should be closed after
// being used.
func NewRemoteReadStreamServer(t *testing.T, series []*prompb.TimeSeries) *RemoteReadServer {
mockStorage := NewPrometheusMockStorage(series)
func NewRemoteReadStreamServer(t *testing.T) *RemoteReadServer {
rrs := &RemoteReadServer{
storage: mockStorage,
series: make([]*prompb.TimeSeries, 0),
}
rrs.server = httptest.NewServer(rrs.getStreamReadHandler(t))
return rrs
}
func (rrs *RemoteReadServer) InitMockStorage(series []*prompb.TimeSeries) {
rrs.storage = NewMockStorage(series)
}
func (rrs *RemoteReadServer) getStreamReadHandler(t *testing.T) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !validateStreamReadHeaders(t, r) {
@@ -174,10 +180,10 @@ func (rrs *RemoteReadServer) getStreamReadHandler(t *testing.T) http.Handler {
for ss.Next() {
series := ss.At()
iter = series.Iterator(iter)
lbls := remote.MergeLabels(labelsToLabelsProto(series.Labels()), nil)
labels := remote.MergeLabels(labelsToLabelsProto(series.Labels()), nil)
frameBytesLeft := maxBytesInFrame
for _, lb := range lbls {
for _, lb := range labels {
frameBytesLeft -= lb.Size()
}
@@ -207,7 +213,7 @@ func (rrs *RemoteReadServer) getStreamReadHandler(t *testing.T) http.Handler {
resp := &prompb.ChunkedReadResponse{
ChunkedSeries: []*prompb.ChunkedSeries{
{Labels: lbls, Chunks: chks},
{Labels: labels, Chunks: chks},
},
QueryIndex: int64(idx),
}
@@ -274,7 +280,6 @@ func validateStreamReadHeaders(t *testing.T, r *http.Request) bool {
return true
}
// GenerateRemoteReadSeries generates a set of remote read series with the given parameters.
func GenerateRemoteReadSeries(start, end, numOfSeries, numOfSamples int64) []*prompb.TimeSeries {
var ts []*prompb.TimeSeries
j := 0
@@ -317,6 +322,141 @@ func generateRemoteReadSamples(idx int, startTime, endTime, numOfSamples int64)
return samples
}
type MockStorage struct {
query *prompb.Query
store []*prompb.TimeSeries
}
func NewMockStorage(series []*prompb.TimeSeries) *MockStorage {
return &MockStorage{store: series}
}
func (ms *MockStorage) Read(_ context.Context, query *prompb.Query, sortSeries bool) (storage.SeriesSet, error) {
if sortSeries {
return nil, fmt.Errorf("unexpected sortSeries=true")
}
if ms.query != nil {
return nil, fmt.Errorf("expected only one call to remote client got: %v", query)
}
ms.query = query
tss := make([]*prompb.TimeSeries, 0, len(ms.store))
for _, s := range ms.store {
var samples []prompb.Sample
for _, sample := range s.Samples {
if sample.Timestamp >= query.StartTimestampMs && sample.Timestamp < query.EndTimestampMs {
samples = append(samples, sample)
}
}
var series prompb.TimeSeries
if len(samples) > 0 {
series.Labels = s.Labels
series.Samples = samples
}
tss = append(tss, &series)
}
return &mockSeriesSet{
tss: tss,
}, nil
}
func (ms *MockStorage) Reset() {
ms.query = nil
}
type mockSeriesSet struct {
tss []*prompb.TimeSeries
next int
}
func (ss *mockSeriesSet) Next() bool {
if ss.next >= len(ss.tss) {
return false
}
ss.next++
return true
}
func (ss *mockSeriesSet) At() storage.Series {
return &mockSeries{
s: ss.tss[ss.next-1],
}
}
func (ss *mockSeriesSet) Err() error {
return nil
}
func (ss *mockSeriesSet) Warnings() annotations.Annotations {
return nil
}
type mockSeries struct {
s *prompb.TimeSeries
}
func (s *mockSeries) Labels() labels.Labels {
a := make(labels.Labels, len(s.s.Labels))
for i, label := range s.s.Labels {
a[i] = labels.Label{
Name: label.Name,
Value: label.Value,
}
}
return a
}
func (s *mockSeries) Iterator(chunkenc.Iterator) chunkenc.Iterator {
return &mockSamplesIterator{
samples: s.s.Samples,
}
}
type mockSamplesIterator struct {
samples []prompb.Sample
next int
}
func (si *mockSamplesIterator) Next() chunkenc.ValueType {
if si.next >= len(si.samples) {
return chunkenc.ValNone
}
si.next++
return chunkenc.ValFloat
}
func (si *mockSamplesIterator) Seek(t int64) chunkenc.ValueType {
for i := range si.samples {
if si.samples[i].Timestamp >= t {
si.next = i + 1
return chunkenc.ValFloat
}
}
return chunkenc.ValNone
}
func (si *mockSamplesIterator) At() (int64, float64) {
s := si.samples[si.next-1]
return s.Timestamp, s.Value
}
func (si *mockSamplesIterator) AtHistogram(*histogram.Histogram) (int64, *histogram.Histogram) {
panic("BUG: mustn't be called")
}
func (si *mockSamplesIterator) AtFloatHistogram(*histogram.FloatHistogram) (int64, *histogram.FloatHistogram) {
panic("BUG: mustn't be called")
}
func (si *mockSamplesIterator) AtT() int64 {
return si.samples[si.next-1].Timestamp
}
func (si *mockSamplesIterator) Err() error {
return nil
}
func labelsToLabelsProto(labels labels.Labels) []prompb.Label {
result := make([]prompb.Label, 0, len(labels))
for _, l := range labels {

View File

@@ -0,0 +1,306 @@
package remote_read_integration
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"reflect"
"sort"
"strconv"
"sync"
"testing"
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/vm"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmselect/prometheus"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/native/stream"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/protoparserutil"
parser "github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/vmimport"
)
// LabelValues represents series from api/v1/series response
type LabelValues map[string]string
// Response represents response from api/v1/series
type Response struct {
Status string `json:"status"`
Series []LabelValues `json:"data"`
}
type MetricNamesResponse struct {
Status string `json:"status"`
Data []string `json:"data"`
}
// RemoteWriteServer represents fake remote write server with database
type RemoteWriteServer struct {
server *httptest.Server
series []vm.TimeSeries
expectedSeries []vm.TimeSeries
tss []vm.TimeSeries
}
// NewRemoteWriteServer prepares test remote write server
func NewRemoteWriteServer(t *testing.T) *RemoteWriteServer {
rws := &RemoteWriteServer{series: make([]vm.TimeSeries, 0)}
mux := http.NewServeMux()
mux.Handle("/api/v1/import", rws.getWriteHandler(t))
mux.Handle("/health", rws.handlePing())
mux.Handle("/api/v1/series", rws.seriesHandler())
mux.Handle("/api/v1/label/__name__/values", rws.valuesHandler())
mux.Handle("/api/v1/export/native", rws.exportNativeHandler())
mux.Handle("/api/v1/import/native", rws.importNativeHandler(t))
rws.server = httptest.NewServer(mux)
return rws
}
// Close closes the server
func (rws *RemoteWriteServer) Close() {
rws.server.Close()
}
// Series saves generated series for fake database
func (rws *RemoteWriteServer) Series(series []vm.TimeSeries) {
rws.series = append(rws.series, series...)
}
// ExpectedSeries saves expected results to check in the handler
func (rws *RemoteWriteServer) ExpectedSeries(series []vm.TimeSeries) {
rws.expectedSeries = append(rws.expectedSeries, series...)
}
func (rws *RemoteWriteServer) GetCollectedTimeSeries() []vm.TimeSeries {
return rws.tss
}
// URL returns server url
func (rws *RemoteWriteServer) URL() string {
return rws.server.URL
}
func (rws *RemoteWriteServer) getWriteHandler(t *testing.T) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
scanner := bufio.NewScanner(r.Body)
var rows parser.Rows
for scanner.Scan() {
rows.Unmarshal(scanner.Text())
for _, row := range rows.Rows {
var labelPairs []vm.LabelPair
var ts vm.TimeSeries
nameValue := ""
for _, tag := range row.Tags {
if string(tag.Key) == "__name__" {
nameValue = string(tag.Value)
continue
}
labelPairs = append(labelPairs, vm.LabelPair{Name: string(tag.Key), Value: string(tag.Value)})
}
ts.Values = append(ts.Values, row.Values...)
ts.Timestamps = append(ts.Timestamps, row.Timestamps...)
ts.Name = nameValue
ts.LabelPairs = labelPairs
rws.tss = append(rws.tss, ts)
}
rows.Reset()
}
w.WriteHeader(http.StatusNoContent)
return
})
}
func (rws *RemoteWriteServer) handlePing() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
})
}
func (rws *RemoteWriteServer) seriesHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var labelValues []LabelValues
for _, ser := range rws.series {
metricNames := make(LabelValues)
if ser.Name != "" {
metricNames["__name__"] = ser.Name
}
for _, p := range ser.LabelPairs {
metricNames[p.Name] = p.Value
}
labelValues = append(labelValues, metricNames)
}
resp := Response{
Status: "success",
Series: labelValues,
}
err := json.NewEncoder(w).Encode(resp)
if err != nil {
log.Printf("error send series: %s", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
})
}
func (rws *RemoteWriteServer) valuesHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
labelNames := make(map[string]struct{})
for _, ser := range rws.series {
if ser.Name != "" {
labelNames[ser.Name] = struct{}{}
}
}
metricNames := make([]string, 0, len(labelNames))
for k := range labelNames {
metricNames = append(metricNames, k)
}
resp := MetricNamesResponse{
Status: "success",
Data: metricNames,
}
buf := bytes.NewBuffer(nil)
err := json.NewEncoder(buf).Encode(resp)
if err != nil {
log.Printf("error send series: %s", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
_, err = w.Write(buf.Bytes())
if err != nil {
log.Printf("error send series: %s", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
return
})
}
func (rws *RemoteWriteServer) exportNativeHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
now := time.Now()
err := prometheus.ExportNativeHandler(now, w, r)
if err != nil {
log.Printf("error export series via native protocol: %s", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
return
})
}
func (rws *RemoteWriteServer) importNativeHandler(t *testing.T) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
protoparserutil.StartUnmarshalWorkers()
defer protoparserutil.StopUnmarshalWorkers()
var gotTimeSeries []vm.TimeSeries
var mx sync.RWMutex
err := stream.Parse(r.Body, "", func(block *stream.Block) error {
mn := &block.MetricName
var timeseries vm.TimeSeries
timeseries.Name = string(mn.MetricGroup)
timeseries.Timestamps = append(timeseries.Timestamps, block.Timestamps...)
timeseries.Values = append(timeseries.Values, block.Values...)
for i := range mn.Tags {
tag := &mn.Tags[i]
timeseries.LabelPairs = append(timeseries.LabelPairs, vm.LabelPair{
Name: string(tag.Key),
Value: string(tag.Value),
})
}
mx.Lock()
gotTimeSeries = append(gotTimeSeries, timeseries)
mx.Unlock()
return nil
})
if err != nil {
log.Printf("error parse stream blocks: %s", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
// got timeseries should be sorted
// because they are processed independently
sort.SliceStable(gotTimeSeries, func(i, j int) bool {
iv, jv := gotTimeSeries[i], gotTimeSeries[j]
switch {
case iv.Values[0] != jv.Values[0]:
return iv.Values[0] < jv.Values[0]
case iv.Timestamps[0] != jv.Timestamps[0]:
return iv.Timestamps[0] < jv.Timestamps[0]
default:
return iv.Name < jv.Name
}
})
if !reflect.DeepEqual(gotTimeSeries, rws.expectedSeries) {
w.WriteHeader(http.StatusInternalServerError)
t.Fatalf("datasets not equal, expected: %#v;\n got: %#v", rws.expectedSeries, gotTimeSeries)
}
w.WriteHeader(http.StatusNoContent)
return
})
}
// GenerateVNSeries generates test timeseries
func GenerateVNSeries(start, end, numOfSeries, numOfSamples int64) []vm.TimeSeries {
var ts []vm.TimeSeries
j := 0
for i := 0; i < int(numOfSeries); i++ {
if i%3 == 0 {
j++
}
timeSeries := vm.TimeSeries{
Name: fmt.Sprintf("vm_metric_%d", j),
LabelPairs: []vm.LabelPair{
{Name: "job", Value: strconv.Itoa(i)},
},
}
ts = append(ts, timeSeries)
}
for i := range ts {
t, v := generateTimeStampsAndValues(i, start, end, numOfSamples)
ts[i].Timestamps = t
ts[i].Values = v
}
return ts
}
func generateTimeStampsAndValues(idx int, startTime, endTime, numOfSamples int64) ([]int64, []float64) {
delta := (endTime - startTime) / numOfSamples
var timestamps []int64
var values []float64
t := startTime
for t != endTime {
v := 100 * int64(idx)
timestamps = append(timestamps, t*1000)
values = append(values, float64(v))
t = t + delta
}
return timestamps, values
}

View File

@@ -1,9 +1,268 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"testing"
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/backoff"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/barpool"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/native"
remote_read_integration "github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/testdata/servers_integration_test"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/vm"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmselect/promql"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httputil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prompbmarshal"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage"
)
const (
storagePath = "TestStorage"
retentionPeriod = "100y"
deleteSeriesLimit = 3e3
)
func TestVMNativeProcessorRun(t *testing.T) {
f := func(startStr, endStr string, numOfSeries, numOfSamples int, resultExpected []vm.TimeSeries) {
t.Helper()
src := remote_read_integration.NewRemoteWriteServer(t)
dst := remote_read_integration.NewRemoteWriteServer(t)
defer func() {
src.Close()
dst.Close()
}()
start, err := time.Parse(time.RFC3339, startStr)
if err != nil {
t.Fatalf("cannot parse start time: %s", err)
}
end, err := time.Parse(time.RFC3339, endStr)
if err != nil {
t.Fatalf("cannot parse end time: %s", err)
}
matchName := "__name__"
matchValue := ".*"
filter := native.Filter{
Match: fmt.Sprintf("{%s=~%q}", matchName, matchValue),
TimeStart: startStr,
TimeEnd: endStr,
}
rws := remote_read_integration.GenerateVNSeries(start.Unix(), end.Unix(), int64(numOfSeries), int64(numOfSamples))
src.Series(rws)
dst.ExpectedSeries(resultExpected)
if err := fillStorage(rws); err != nil {
t.Fatalf("cannot add series to storage: %s", err)
}
tr := httputil.NewTransport(false, "test_client")
tr.DisableKeepAlives = false
srcClient := &native.Client{
AuthCfg: nil,
Addr: src.URL(),
ExtraLabels: []string{},
HTTPClient: &http.Client{
Transport: tr,
},
}
dstClient := &native.Client{
AuthCfg: nil,
Addr: dst.URL(),
ExtraLabels: []string{},
HTTPClient: &http.Client{
Transport: tr,
},
}
isSilent = true
defer func() { isSilent = false }()
bf, err := backoff.New(10, 1.8, time.Second*2)
if err != nil {
t.Fatalf("cannot create backoff: %s", err)
}
p := &vmNativeProcessor{
filter: filter,
dst: dstClient,
src: srcClient,
backoff: bf,
cc: 1,
isNative: true,
}
ctx := context.Background()
if err := p.run(ctx); err != nil {
t.Fatalf("run() error: %s", err)
}
deleted, err := deleteSeries(matchName, matchValue)
if err != nil {
t.Fatalf("cannot delete series: %s", err)
}
if deleted != numOfSeries {
t.Fatalf("unexpected number of deleted series; got %d; want %d", deleted, numOfSeries)
}
}
processFlags()
vmstorage.Init(promql.ResetRollupResultCacheIfNeeded)
defer func() {
vmstorage.Stop()
if err := os.RemoveAll(storagePath); err != nil {
log.Fatalf("cannot remove %q: %s", storagePath, err)
}
}()
barpool.Disable(true)
defer func() {
barpool.Disable(false)
}()
// step minute on minute time range
start := "2022-11-25T11:23:05+02:00"
end := "2022-11-27T11:24:05+02:00"
numOfSeries := 3
numOfSamples := 2
resultExpected := []vm.TimeSeries{
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "0"}},
Timestamps: []int64{1669368185000, 1669454615000},
Values: []float64{0, 0},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "1"}},
Timestamps: []int64{1669368185000, 1669454615000},
Values: []float64{100, 100},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "2"}},
Timestamps: []int64{1669368185000, 1669454615000},
Values: []float64{200, 200},
},
}
f(start, end, numOfSeries, numOfSamples, resultExpected)
// step month on month time range
start = "2022-09-26T11:23:05+02:00"
end = "2022-11-26T11:24:05+02:00"
numOfSeries = 3
numOfSamples = 2
resultExpected = []vm.TimeSeries{
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "0"}},
Timestamps: []int64{1664184185000},
Values: []float64{0},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "0"}},
Timestamps: []int64{1666819415000},
Values: []float64{0},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "1"}},
Timestamps: []int64{1664184185000},
Values: []float64{100},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "1"}},
Timestamps: []int64{1666819415000},
Values: []float64{100},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "2"}},
Timestamps: []int64{1664184185000},
Values: []float64{200},
},
{
Name: "vm_metric_1",
LabelPairs: []vm.LabelPair{{Name: "job", Value: "2"}},
Timestamps: []int64{1666819415000},
Values: []float64{200},
},
}
f(start, end, numOfSeries, numOfSamples, resultExpected)
}
func processFlags() {
flag.Parse()
for _, fv := range []struct {
flag string
value string
}{
{flag: "storageDataPath", value: storagePath},
{flag: "retentionPeriod", value: retentionPeriod},
} {
// panics if flag doesn't exist
if err := flag.Lookup(fv.flag).Value.Set(fv.value); err != nil {
log.Fatalf("unable to set %q with value %q, err: %v", fv.flag, fv.value, err)
}
}
}
func fillStorage(series []vm.TimeSeries) error {
var mrs []storage.MetricRow
for _, series := range series {
var labels []prompbmarshal.Label
for _, lp := range series.LabelPairs {
labels = append(labels, prompbmarshal.Label{
Name: lp.Name,
Value: lp.Value,
})
}
if series.Name != "" {
labels = append(labels, prompbmarshal.Label{
Name: "__name__",
Value: series.Name,
})
}
mr := storage.MetricRow{}
mr.MetricNameRaw = storage.MarshalMetricNameRaw(mr.MetricNameRaw[:0], labels)
timestamps := series.Timestamps
values := series.Values
for i, value := range values {
mr.Timestamp = timestamps[i]
mr.Value = value
mrs = append(mrs, mr)
}
}
if err := vmstorage.AddRows(mrs); err != nil {
return fmt.Errorf("unexpected error in AddRows: %s", err)
}
vmstorage.Storage.DebugFlush()
return nil
}
func deleteSeries(name, value string) (int, error) {
tfs := storage.NewTagFilters()
if err := tfs.Add([]byte(name), []byte(value), false, true); err != nil {
return 0, fmt.Errorf("unexpected error in TagFilters.Add: %w", err)
}
return vmstorage.DeleteSeries(nil, []*storage.TagFilters{tfs}, deleteSeriesLimit)
}
func TestBuildMatchWithFilter_Failure(t *testing.T) {
f := func(filter, metricName string) {
t.Helper()

View File

@@ -15,7 +15,7 @@ import (
)
var maxGraphiteSeries = flag.Int("search.maxGraphiteSeries", 300e3, "The maximum number of time series, which can be scanned during queries to Graphite Render API. "+
"See https://docs.victoriametrics.com/victoriametrics/integrations/graphite/#render-api")
"See https://docs.victoriametrics.com/victoriametrics/integrations/graphite#render-api")
type evalConfig struct {
startTime int64

View File

@@ -22,9 +22,9 @@ import (
var (
maxGraphiteTagKeysPerSearch = flag.Int("search.maxGraphiteTagKeys", 100e3, "The maximum number of tag keys returned from Graphite API, which returns tags. "+
"See https://docs.victoriametrics.com/victoriametrics/integrations/graphite/#tags-api")
"See https://docs.victoriametrics.com/victoriametrics/integrations/graphite#tags-api")
maxGraphiteTagValuesPerSearch = flag.Int("search.maxGraphiteTagValues", 100e3, "The maximum number of tag values returned from Graphite API, which returns tag values. "+
"See https://docs.victoriametrics.com/victoriametrics/integrations/graphite/#tags-api")
"See https://docs.victoriametrics.com/victoriametrics/integrations/graphite#tags-api")
)
// TagsDelSeriesHandler implements /tags/delSeries handler.

View File

@@ -818,7 +818,6 @@ func QueryHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWr
LookbackDelta: lookbackDelta,
RoundDigits: getRoundDigits(r),
EnforcedTagFilterss: etfs,
CacheTagFilters: etfs,
GetRequestURI: func() string {
return httpserver.GetRequestURI(r)
},
@@ -928,7 +927,6 @@ func queryRangeHandler(qt *querytracer.Tracer, startTime time.Time, w http.Respo
LookbackDelta: lookbackDelta,
RoundDigits: getRoundDigits(r),
EnforcedTagFilterss: etfs,
CacheTagFilters: etfs,
GetRequestURI: func() string {
return httpserver.GetRequestURI(r)
},

View File

@@ -5,10 +5,8 @@ import (
"strings"
"unsafe"
"github.com/VictoriaMetrics/metricsql"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmselect/netstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/atomicutil"
"github.com/VictoriaMetrics/metricsql"
)
// callbacks for optimized incremental calculations for aggregate functions
@@ -68,8 +66,9 @@ var incrementalAggrFuncCallbacksMap = map[string]*incrementalAggrFuncCallbacks{
type incrementalAggrContextMap struct {
m map[string]*incrementalAggrContext
// The padding prevents false sharing
_ [atomicutil.CacheLineSize - unsafe.Sizeof(map[string]*incrementalAggrContext{})%atomicutil.CacheLineSize]byte
// The padding prevents false sharing on widespread platforms with
// 128 mod (cache line size) = 0 .
_ [128 - unsafe.Sizeof(map[string]*incrementalAggrContext{})%128]byte
}
type incrementalAggrFuncContext struct {

View File

@@ -17,7 +17,6 @@ import (
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmselect/netstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmselect/searchutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/atomicutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/bytesutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/cgroup"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/decimal"
@@ -140,13 +139,6 @@ type EvalConfig struct {
// EnforcedTagFilterss may contain additional label filters to use in the query.
EnforcedTagFilterss [][]storage.TagFilter
// CacheTagFilters stores the original tag-filter sets and extra_label from the request.
// The slice is never modified after creation and is used only to build
// the query-cache key.
//
// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9001
CacheTagFilters [][]storage.TagFilter
// The callback, which returns the request URI during logging.
// The request URI isn't stored here because its' construction may take non-trivial amounts of CPU.
GetRequestURI func() string
@@ -173,7 +165,6 @@ func copyEvalConfig(src *EvalConfig) *EvalConfig {
ec.LookbackDelta = src.LookbackDelta
ec.RoundDigits = src.RoundDigits
ec.EnforcedTagFilterss = src.EnforcedTagFilterss
ec.CacheTagFilters = src.CacheTagFilters
ec.GetRequestURI = src.GetRequestURI
ec.QueryStats = src.QueryStats
@@ -1894,8 +1885,9 @@ func doRollupForTimeseries(funcName string, keepMetricNames bool, rc *rollupConf
type timeseriesWithPadding struct {
tss []*timeseries
// The padding prevents false sharing
_ [atomicutil.CacheLineSize - unsafe.Sizeof([]*timeseries{})%atomicutil.CacheLineSize]byte
// The padding prevents false sharing on widespread platforms with
// 128 mod (cache line size) = 0 .
_ [128 - unsafe.Sizeof([]*timeseries{})%128]byte
}
type timeseriesByWorkerID struct {
@@ -1974,14 +1966,11 @@ func sumNoOverflow(a, b int64) int64 {
}
func dropStaleNaNs(funcName string, values []float64, timestamps []int64) ([]float64, []int64) {
if *noStaleMarkers || funcName == "stale_samples_over_time" ||
funcName == "default_rollup" || funcName == "increase" || funcName == "rate" {
if *noStaleMarkers || funcName == "default_rollup" || funcName == "stale_samples_over_time" {
// Do not drop Prometheus staleness marks (aka stale NaNs) for default_rollup() function,
// since it uses them for Prometheus-style staleness detection.
// Do not drop staleness marks for stale_samples_over_time() function, since it needs
// to calculate the number of staleness markers.
// Do not drop staleness marks for increase() and rate() function, so they could stop
// returning results for stale series. See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/8891
return values, timestamps
}
// Remove Prometheus staleness marks, so non-default rollup functions don't hit NaN values.

View File

@@ -71,8 +71,7 @@ var rollupFuncs = map[string]newRollupFunc{
"quantile_over_time": newRollupQuantile,
"quantiles_over_time": newRollupQuantiles,
"range_over_time": newRollupFuncOneArg(rollupRange),
"rate": newRollupFuncOneArg(rollupDerivFast), // + rollupFuncsRemoveCounterResets
"rate_prometheus": newRollupFuncOneArg(rollupDerivFastPrometheus), // + rollupFuncsRemoveCounterResets
"rate": newRollupFuncOneArg(rollupDerivFast), // + rollupFuncsRemoveCounterResets
"rate_over_sum": newRollupFuncOneArg(rollupRateOverSum),
"resets": newRollupFuncOneArg(rollupResets),
"rollup": newRollupFuncOneOrTwoArgs(rollupFake),
@@ -196,7 +195,7 @@ var rollupAggrFuncs = map[string]rollupFunc{
"zscore_over_time": rollupZScoreOverTime,
}
// VictoriaMetrics can extend lookbehind window for these functions
// VictoriaMetrics can extends lookbehind window for these functions
// in order to make sure it contains enough points for returning non-empty results.
//
// This is needed for returning the expected non-empty graphs when zooming in the graph in Grafana,
@@ -226,7 +225,6 @@ var rollupFuncsRemoveCounterResets = map[string]bool{
"increase_pure": true,
"irate": true,
"rate": true,
"rate_prometheus": true,
"rollup_increase": true,
"rollup_rate": true,
}
@@ -254,7 +252,6 @@ var rollupFuncsSamplesScannedPerCall = map[string]int{
"lifetime": 2,
"present_over_time": 1,
"rate": 2,
"rate_prometheus": 2,
"scrape_interval": 2,
"tfirst_over_time": 1,
"timestamp": 1,
@@ -532,7 +529,7 @@ type rollupFuncArg struct {
timestamps []int64
// Real value preceding values.
// Is populated if preceding value is within the rc.LookbackDelta.
// Is populated if preceding value is within the -search.maxStalenessInterval (rc.LookbackDelta).
realPrevValue float64
// Real value which goes after values.
@@ -779,18 +776,13 @@ func (rc *rollupConfig) doInternal(dstValues []float64, tsm *timeseriesMap, valu
rfa.realPrevValue = nan
if i > 0 {
prevValue, prevTimestamp := values[i-1], timestamps[i-1]
// set realPrevValue if rc.LookbackDelta == 0 or
// if distance between datapoint in prev interval and first datapoint in this interval
// set realPrevValue if rc.LookbackDelta == 0
// or if distance between datapoint in prev interval and beginning of this interval
// doesn't exceed LookbackDelta.
// https://github.com/VictoriaMetrics/VictoriaMetrics/pull/1381
// https://github.com/VictoriaMetrics/VictoriaMetrics/issues/894
// https://github.com/VictoriaMetrics/VictoriaMetrics/issues/8045
// https://github.com/VictoriaMetrics/VictoriaMetrics/issues/8935
currTimestamp := tStart
if len(rfa.timestamps) > 0 {
currTimestamp = rfa.timestamps[0]
}
if rc.LookbackDelta == 0 || (currTimestamp-prevTimestamp) < rc.LookbackDelta {
if rc.LookbackDelta == 0 || (tStart-prevTimestamp) < rc.LookbackDelta {
rfa.realPrevValue = prevValue
}
}
@@ -916,18 +908,15 @@ func getMaxPrevInterval(scrapeInterval int64) int64 {
return scrapeInterval + scrapeInterval/8
}
// removeCounterResets removes resets for rollup functions over counters - see rollupFuncsRemoveCounterResets
// it doesn't remove resets between samples with staleNaNs, or samples that exceed maxStalenessInterval
func removeCounterResets(values []float64, timestamps []int64, maxStalenessInterval int64) {
// There is no need in handling NaNs here, since they are impossible
// on values from vmstorage.
if len(values) == 0 {
return
}
var correction float64
prevValue := values[0]
for i, v := range values {
if decimal.IsStaleNaN(v) {
continue
}
d := v - prevValue
if d < 0 {
if (-d * 8) < prevValue {
@@ -1837,18 +1826,14 @@ func rollupIncreasePure(rfa *rollupFuncArg) float64 {
// There is no need in handling NaNs here, since they must be cleaned up
// before calling rollup funcs.
values := rfa.values
prevValue := rfa.prevValue
// restore to the real value because of potential staleness reset
prevValue := rfa.realPrevValue
if math.IsNaN(prevValue) {
if len(values) == 0 {
return nan
}
// Assume the counter starts from 0.
prevValue = 0
if !math.IsNaN(rfa.realPrevValue) {
// Assume that the value didn't change during the current gap
// if realPrevValue exists.
prevValue = rfa.realPrevValue
}
}
if len(values) == 0 {
// Assume the counter didn't change since prevValue.
@@ -1859,13 +1844,8 @@ func rollupIncreasePure(rfa *rollupFuncArg) float64 {
func rollupDelta(rfa *rollupFuncArg) float64 {
// There is no need in handling NaNs here, since they must be cleaned up
// before calling rollup funcs. Only StaleNaNs could remain in values - see dropStaleNaNs().
// before calling rollup funcs.
values := rfa.values
if len(values) > 0 && decimal.IsStaleNaN(values[len(values)-1]) {
// if last sample on interval is staleness marker then the selected series is expected
// to stop rendering immediately. See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/8891
return nan
}
prevValue := rfa.prevValue
if math.IsNaN(prevValue) {
if len(values) == 0 {
@@ -1949,23 +1929,10 @@ func rollupDerivSlow(rfa *rollupFuncArg) float64 {
return k
}
func rollupDerivFastPrometheus(rfa *rollupFuncArg) float64 {
delta := rollupDeltaPrometheus(rfa)
if math.IsNaN(delta) || rfa.window == 0 {
return nan
}
return delta / (float64(rfa.window) / 1e3)
}
func rollupDerivFast(rfa *rollupFuncArg) float64 {
// There is no need in handling NaNs here, since they must be cleaned up
// before calling rollup funcs. Only StaleNaNs could remain in values - see - see dropStaleNaNs().
// before calling rollup funcs.
values := rfa.values
if len(values) > 0 && decimal.IsStaleNaN(values[len(values)-1]) {
// if last sample on interval is staleness marker then the selected series is expected
// to stop rendering immediately. See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/8891
return nan
}
timestamps := rfa.timestamps
prevValue := rfa.prevValue
prevTimestamp := rfa.prevTimestamp

View File

@@ -291,7 +291,7 @@ func (rrc *rollupResultCache) GetSeries(qt *querytracer.Tracer, ec *EvalConfig,
bb := bbPool.Get()
defer bbPool.Put(bb)
bb.B = marshalRollupResultCacheKeyForSeries(bb.B[:0], expr, window, ec.Step, ec.CacheTagFilters)
bb.B = marshalRollupResultCacheKeyForSeries(bb.B[:0], expr, window, ec.Step, ec.EnforcedTagFilterss)
metainfoBuf := rrc.c.Get(nil, bb.B)
if len(metainfoBuf) == 0 {
qt.Printf("nothing found")
@@ -313,7 +313,7 @@ func (rrc *rollupResultCache) GetSeries(qt *querytracer.Tracer, ec *EvalConfig,
if !ok {
mi.RemoveKey(key)
metainfoBuf = mi.Marshal(metainfoBuf[:0])
bb.B = marshalRollupResultCacheKeyForSeries(bb.B[:0], expr, window, ec.Step, ec.CacheTagFilters)
bb.B = marshalRollupResultCacheKeyForSeries(bb.B[:0], expr, window, ec.Step, ec.EnforcedTagFilterss)
rrc.c.Set(bb.B, metainfoBuf)
return nil, ec.Start
}
@@ -419,7 +419,7 @@ func (rrc *rollupResultCache) PutSeries(qt *querytracer.Tracer, ec *EvalConfig,
metainfoBuf := bbPool.Get()
defer bbPool.Put(metainfoBuf)
metainfoKey.B = marshalRollupResultCacheKeyForSeries(metainfoKey.B[:0], expr, window, ec.Step, ec.CacheTagFilters)
metainfoKey.B = marshalRollupResultCacheKeyForSeries(metainfoKey.B[:0], expr, window, ec.Step, ec.EnforcedTagFilterss)
metainfoBuf.B = rrc.c.Get(metainfoBuf.B[:0], metainfoKey.B)
var mi rollupResultCacheMetainfo
if len(metainfoBuf.B) > 0 {

View File

@@ -156,14 +156,6 @@ func TestRemoveCounterResets(t *testing.T) {
removeCounterResets(values, timestamps, 10)
testRowsEqual(t, values, timestamps, valuesExpected, timestamps)
// verify that staleNaNs are respected
// it is important to have counter reset in values below to trigger correction logic
values = []float64{2, 4, 2, decimal.StaleNaN}
timestamps = []int64{10, 20, 30, 40}
valuesExpected = []float64{2, 4, 6, decimal.StaleNaN}
removeCounterResets(values, timestamps, 10)
testRowsEqual(t, values, timestamps, valuesExpected, timestamps)
// verify results always increase monotonically with possible float operations precision error
values = []float64{34.094223, 2.7518, 2.140669, 0.044878, 1.887095, 2.546569, 2.490149, 0.045, 0.035684, 0.062454, 0.058296}
timestampsExpected = []int64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
@@ -656,7 +648,6 @@ func TestRollupNewRollupFuncSuccess(t *testing.T) {
f("irate", 0)
f("outlier_iqr_over_time", nan)
f("rate", 2200)
f("rate_prometheus", 2200)
f("resets", 5)
f("range_over_time", 111)
f("avg_over_time", 47.083333333333336)
@@ -1534,31 +1525,16 @@ func testRowsEqual(t *testing.T, values []float64, timestamps []int64, valuesExp
i, ts, tsExpected, timestamps, timestampsExpected)
}
vExpected := valuesExpected[i]
if decimal.IsStaleNaN(v) {
if !decimal.IsStaleNaN(vExpected) {
t.Fatalf("unexpected stale NaN value at values[%d]; want %f\nvalues=\n%v\nvaluesExpected=\n%v",
i, vExpected, values, valuesExpected)
}
continue
}
// staleNaNBits == math.NaN(), but decimal.IsStaleNaN(math.NaN()) == false
// so we check for decimal.IsStaleNaN first.
if decimal.IsStaleNaN(vExpected) {
if !decimal.IsStaleNaN(v) {
t.Fatalf("unexpected value at values[%d]; got %f; want stale NaN\nvalues=\n%v\nvaluesExpected=\n%v",
i, v, values, valuesExpected)
}
}
if math.IsNaN(v) {
if !math.IsNaN(vExpected) {
t.Fatalf("unexpected NaN value at values[%d]; want %f\nvalues=\n%v\nvaluesExpected=\n%v",
t.Fatalf("unexpected nan value at values[%d]; want %f\nvalues=\n%v\nvaluesExpected=\n%v",
i, vExpected, values, valuesExpected)
}
continue
}
if math.IsNaN(vExpected) {
if !math.IsNaN(v) {
t.Fatalf("unexpected value at values[%d]; got %f; want NaN\nvalues=\n%v\nvaluesExpected=\n%v",
t.Fatalf("unexpected value at values[%d]; got %f; want nan\nvalues=\n%v\nvaluesExpected=\n%v",
i, v, values, valuesExpected)
}
continue
@@ -1632,33 +1608,6 @@ func TestRollupDelta(t *testing.T) {
f(100, nan, nan, nil, 0)
}
func TestRollupDerivFastPrometheus(t *testing.T) {
f := func(values []float64, window int64, resultExpected float64) {
t.Helper()
rfa := &rollupFuncArg{
values: values,
window: window,
}
result := rollupDerivFastPrometheus(rfa)
if math.IsNaN(result) {
if !math.IsNaN(resultExpected) {
t.Fatalf("unexpected result; got %v; want %v", result, resultExpected)
}
return
}
if result != resultExpected {
t.Fatalf("unexpected result; got %v; want %v", result, resultExpected)
}
}
f(nil, 0, nan)
f(nil, 10, nan)
f([]float64{0, 10}, 0, nan)
f([]float64{10}, 10, nan)
f([]float64{0, 20}, 10e3, 2)
f([]float64{0, 10, 20}, 10e3, 2)
}
func TestRollupDeltaWithStaleness(t *testing.T) {
// there is a gap between samples in the dataset below
timestamps := []int64{0, 15000, 30000, 70000}
@@ -1770,55 +1719,6 @@ func TestRollupDeltaWithStaleness(t *testing.T) {
timestampsExpected := []int64{0, 10e3, 20e3, 30e3, 40e3}
testRowsEqual(t, gotValues, rc.Timestamps, valuesExpected, timestampsExpected)
})
t.Run("issue-8935", func(t *testing.T) {
// https://github.com/VictoriaMetrics/VictoriaMetrics/issues/8935
// below dataset has a gap that exceeds LookbackDelta.
// The step is picked in a way that on [60e3-90e3] window
// the prevValue will be NaN, but 60e3-55e3 still matches
// timestamp=10e3 and stores its value as realPrevValue.
// This results into delta=1-50=-49 increase result.
// The fix makes it to deduct LookbackDelta not from window start
// but from first captured data point in the window, so it becomes 70e3-55e3=15e3.
// And realPrevValue becomes NaN due to staleness detection.
timestamps = []int64{0, 10000, 70000, 80000}
values = []float64{50, 50, 1, 1}
rc := rollupConfig{
Func: rollupDelta,
Start: 0,
End: 90e3,
Step: 30e3,
LookbackDelta: 55e3,
MaxPointsPerSeries: 1e4,
}
rc.Timestamps = rc.getTimestamps()
gotValues, _ := rc.Do(nil, values, timestamps)
valuesExpected := []float64{0, 0, 0, 1}
timestampsExpected := []int64{0, 30e3, 60e3, 90e3}
testRowsEqual(t, gotValues, rc.Timestamps, valuesExpected, timestampsExpected)
})
// the last sample is stale NaN
timestamps = []int64{0, 10000, 20000, 30000, 40000}
values = []float64{0, 0, 0, 10, decimal.StaleNaN}
t.Run("last point is stale nan", func(t *testing.T) {
rc := rollupConfig{
Func: rollupDelta,
Start: 40001,
End: 40001,
Step: 50000,
Window: 0,
MaxPointsPerSeries: 1e4,
}
rc.Timestamps = rc.getTimestamps()
gotValues, samplesScanned := rc.Do(nil, values, timestamps)
if samplesScanned != 10 {
t.Fatalf("expecting 10 samplesScanned from rollupConfig.Do; got %d", samplesScanned)
}
valuesExpected := []float64{nan}
timestampsExpected := []int64{40001}
testRowsEqual(t, gotValues, rc.Timestamps, valuesExpected, timestampsExpected)
})
}
func TestRollupIncreasePureWithStaleness(t *testing.T) {
@@ -1933,48 +1833,3 @@ func TestRollupIncreasePureWithStaleness(t *testing.T) {
testRowsEqual(t, gotValues, rc.Timestamps, valuesExpected, timestampsExpected)
})
}
func TestRollupDerivFastWithStaleness(t *testing.T) {
timestamps := []int64{0, 10000, 20000, 30000, 40000}
values := []float64{0, 0, 0, 0, 10}
t.Run("no stale marker", func(t *testing.T) {
rc := rollupConfig{
Func: rollupDerivFast,
Start: 40001,
End: 40001,
Step: 50000,
Window: 0,
MaxPointsPerSeries: 1e4,
}
rc.Timestamps = rc.getTimestamps()
gotValues, samplesScanned := rc.Do(nil, values, timestamps)
if samplesScanned != 10 {
t.Fatalf("expecting 10 samplesScanned from rollupConfig.Do; got %d", samplesScanned)
}
valuesExpected := []float64{0.25}
timestampsExpected := []int64{40001}
testRowsEqual(t, gotValues, rc.Timestamps, valuesExpected, timestampsExpected)
})
// the last sample is stale NaN
timestamps = []int64{0, 10000, 20000, 30000, 40000}
values = []float64{0, 0, 0, 10, decimal.StaleNaN}
t.Run("last point is stale nan", func(t *testing.T) {
rc := rollupConfig{
Func: rollupDerivFast,
Start: 40001,
End: 40001,
Step: 50000,
Window: 0,
MaxPointsPerSeries: 1e4,
}
rc.Timestamps = rc.getTimestamps()
gotValues, samplesScanned := rc.Do(nil, values, timestamps)
if samplesScanned != 10 {
t.Fatalf("expecting 10 samplesScanned from rollupConfig.Do; got %d", samplesScanned)
}
valuesExpected := []float64{nan}
timestampsExpected := []int64{40001}
testRowsEqual(t, gotValues, rc.Timestamps, valuesExpected, timestampsExpected)
})
}

View File

@@ -66,8 +66,8 @@ or at your own [VictoriaMetrics instance](https://docs.victoriametrics.com/victo
The list of MetricsQL features on top of PromQL:
* Graphite-compatible filters can be passed via `{__graphite__="foo.*.bar"}` syntax.
See [these docs](https://docs.victoriametrics.com/victoriametrics/integrations/graphite/#selecting-graphite-metrics).
VictoriaMetrics can be used as Graphite datasource in Grafana. See [these docs](https://docs.victoriametrics.com/victoriametrics/integrations/graphite/#graphite-api-usage) for details.
See [these docs](https://docs.victoriametrics.com/victoriametrics/integrations/graphite#selecting-graphite-metrics).
VictoriaMetrics can be used as Graphite datasource in Grafana. See [these docs](https://docs.victoriametrics.com/victoriametrics/integrations/graphite#graphite-api-usage) for details.
See also [label_graphite_group](#label_graphite_group) function, which can be used for extracting the given groups from Graphite metric name.
* Lookbehind window in square brackets for [rollup functions](#rollup-functions) may be omitted. VictoriaMetrics automatically selects the lookbehind window
depending on the `step` query arg passed to [/api/v1/query_range](https://docs.victoriametrics.com/victoriametrics/keyconcepts/#range-query)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -36,10 +36,10 @@
<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-D-ssBbZq.js"></script>
<script type="module" crossorigin src="./assets/index-xmjGcv4-.js"></script>
<link rel="modulepreload" crossorigin href="./assets/vendor-D8IJGiEn.js">
<link rel="stylesheet" crossorigin href="./assets/vendor-D1GxaB_c.css">
<link rel="stylesheet" crossorigin href="./assets/index-D5re9hC6.css">
<link rel="stylesheet" crossorigin href="./assets/index-C85_NB5q.css">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@@ -1,4 +1,4 @@
FROM golang:1.24.4 AS build-web-stage
FROM golang:1.24.3 AS build-web-stage
COPY build /build
WORKDIR /build
@@ -6,7 +6,7 @@ COPY web/ /build/
RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o web-amd64 github.com/VictoriMetrics/vmui/ && \
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o web-windows github.com/VictoriMetrics/vmui/
FROM alpine:3.22.0
FROM alpine:3.21.3
USER root
COPY --from=build-web-stage /build/web-amd64 /app/web

View File

@@ -66,8 +66,8 @@ or at your own [VictoriaMetrics instance](https://docs.victoriametrics.com/victo
The list of MetricsQL features on top of PromQL:
* Graphite-compatible filters can be passed via `{__graphite__="foo.*.bar"}` syntax.
See [these docs](https://docs.victoriametrics.com/victoriametrics/integrations/graphite/#selecting-graphite-metrics).
VictoriaMetrics can be used as Graphite datasource in Grafana. See [these docs](https://docs.victoriametrics.com/victoriametrics/integrations/graphite/#graphite-api-usage) for details.
See [these docs](https://docs.victoriametrics.com/victoriametrics/integrations/graphite#selecting-graphite-metrics).
VictoriaMetrics can be used as Graphite datasource in Grafana. See [these docs](https://docs.victoriametrics.com/victoriametrics/integrations/graphite#graphite-api-usage) for details.
See also [label_graphite_group](#label_graphite_group) function, which can be used for extracting the given groups from Graphite metric name.
* Lookbehind window in square brackets for [rollup functions](#rollup-functions) may be omitted. VictoriaMetrics automatically selects the lookbehind window
depending on the `step` query arg passed to [/api/v1/query_range](https://docs.victoriametrics.com/victoriametrics/keyconcepts/#range-query)

View File

@@ -33,12 +33,8 @@ const LogsQueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
const part = logicalParts.find(p => caretPosition[0] >= p.position[0] && caretPosition[0] <= p.position[1]);
if (!part) return;
const cursorStartPosition = caretPosition[0] - part.position[0];
const prevPart = logicalParts.find(p => p.id === part.id - 1);
const queryBeforeIncompleteFilter = prevPart ? value.substring(0, prevPart.position[1] + 1) : undefined;
return {
...part,
queryBeforeIncompleteFilter,
query: value,
...getContextData(part, cursorStartPosition)
};
}, [logicalParts, caretPosition]);
@@ -54,8 +50,6 @@ const LogsQueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
return fieldValues;
case ContextType.PipeName:
return pipeList;
case ContextType.FilterOrPipeName:
return [...fieldNames, ...pipeList];
default:
return [];
}
@@ -64,7 +58,7 @@ const LogsQueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
const getUpdatedValue = (insertValue: string, logicalParts: LogicalPart[], id?: number) => {
return logicalParts.reduce((acc, part) => {
const value = part.id === id ? insertValue : part.value;
const separator = part.separator === "|" ? " | " : " ";
const separator = part.type === LogicalPartType.Pipe ? " | " : " ";
return `${acc}${separator}${value}`;
}, "").trim();
};
@@ -76,7 +70,7 @@ const LogsQueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
modifiedInsert += ":";
} else if (contextType === ContextType.FilterValue) {
const insertWithQuotes = value.startsWith("_stream:") ? modifiedInsert : `${JSON.stringify(modifiedInsert)}`;
modifiedInsert = `${contextData?.filterName || ""}${contextData?.operator || ":"}${insertWithQuotes}`;
modifiedInsert = `${contextData?.filterName || ""}:${insertWithQuotes}`;
}
return modifiedInsert;
@@ -92,13 +86,7 @@ const LogsQueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
const insertValue = getModifyInsert(insert, contextType, value, item.type);
const newValue = getUpdatedValue(insertValue, logicalParts, id);
const logicalPart = logicalParts.find(p => p.id === id);
const getPositionCorrection = () => {
if (logicalPart?.type === LogicalPartType.FilterOrPipe) return 1;
if (item.type === ContextType.PipeName) return 1;
return 0;
};
const updatedPosition = (position[0] || 1) + insertValue.length + getPositionCorrection();
const updatedPosition = (position[0] || 1) + insertValue.length + (item.type === ContextType.PipeName ? 1 : 0);
onSelect(newValue, updatedPosition);
}, [contextData, logicalParts]);

View File

@@ -9,7 +9,6 @@ export const splitLogicalParts = (expr: string) => {
const input = expr; //.replace(/\s*:\s*/g, ":");
const parts: LogicalPart[] = [];
let currentPart = "";
let separator: undefined | " " | "|" = undefined;
let isPipePart = false;
const quotes = ["'", "\"", "`"];
@@ -44,9 +43,8 @@ export const splitLogicalParts = (expr: string) => {
isPipePart = true;
const countStartSpaces = currentPart.match(/^ */)?.[0].length || 0;
const countEndSpaces = currentPart.match(/ *$/)?.[0].length || 0;
pushPart(currentPart, true, [startIndex + countStartSpaces, i - countEndSpaces - 1], parts, separator);
pushPart(currentPart, true, [startIndex + countStartSpaces, i - countEndSpaces - 1], parts);
currentPart = "";
separator = "|";
startIndex = i + 1;
continue;
}
@@ -56,8 +54,7 @@ export const splitLogicalParts = (expr: string) => {
const nextStr = input.slice(i).replace(/^\s*/, "");
const prevStr = input.slice(0, i).replace(/\s*$/, "");
if (!nextStr.startsWith(":") && !prevStr.endsWith(":")) {
pushPart(currentPart, false, [startIndex, i - 1], parts, separator);
separator = " ";
pushPart(currentPart, false, [startIndex, i - 1], parts);
currentPart = "";
startIndex = i + 1;
continue;
@@ -68,35 +65,26 @@ export const splitLogicalParts = (expr: string) => {
}
// push the last part
pushPart(currentPart, isPipePart, [startIndex, input.length], parts, separator);
pushPart(currentPart, isPipePart, [startIndex, input.length], parts);
return parts;
};
const pushPart = (currentPart: string, isPipePart: boolean, position: LogicalPartPosition, parts: LogicalPart[], separator: LogicalPart["separator"]) => {
const pushPart = (currentPart: string, isPipePart: boolean, position: LogicalPartPosition, parts: LogicalPart[]) => {
const trimmedPart = currentPart.trim();
if (!trimmedPart) return;
const isOperator = BUILDER_OPERATORS.includes(trimmedPart.toUpperCase());
const pipesTypes = [LogicalPartType.Pipe, LogicalPartType.FilterOrPipe];
const isPreviousPartPipe = parts.length > 0 && pipesTypes.includes(parts[parts.length - 1].type);
const getType = () => {
if (isPreviousPartPipe) return LogicalPartType.FilterOrPipe;
if (isPipePart) return LogicalPartType.Pipe;
if (isOperator) return LogicalPartType.Operator;
return LogicalPartType.Filter;
};
parts.push({
id: parts.length,
value: trimmedPart,
position,
type: getType(),
separator,
type: isPipePart
? LogicalPartType.Pipe
: isOperator ? LogicalPartType.Operator : LogicalPartType.Filter,
});
};
export const getContextData = (part: LogicalPart, cursorPos: number): ContextData => {
export const getContextData = (part: LogicalPart, cursorPos: number) => {
const valueBeforeCursor = part.value.substring(0, cursorPos);
const valueAfterCursor = part.value.substring(cursorPos);
@@ -107,91 +95,23 @@ export const getContextData = (part: LogicalPart, cursorPos: number): ContextDat
contextType: ContextType.Unknown,
};
// Determine context type based on logical part type
determineContextType(part, valueBeforeCursor, valueAfterCursor, metaData);
if (part.type === LogicalPartType.Filter) {
const noColon = !valueBeforeCursor.includes(":") && !valueAfterCursor.includes(":");
if (noColon) {
metaData.contextType = ContextType.FilterUnknown;
} else if (valueBeforeCursor.includes(":")) {
const [filterName, filterValue] = valueBeforeCursor.split(":");
metaData.contextType = ContextType.FilterValue;
metaData.filterName = filterName;
metaData.valueContext = filterValue;
} else {
metaData.contextType = ContextType.FilterName;
}
} else if (part.type === LogicalPartType.Pipe) {
const valueStartWithPipe = PIPE_NAMES.some(p => part.value.startsWith(p));
metaData.contextType = valueStartWithPipe ? ContextType.PipeValue : ContextType.PipeName;
}
// Clean up quotes in valueContext
metaData.valueContext = metaData.valueContext.replace(/^["']|["']$/g, "");
return metaData;
};
/** Helper function to determine if a string starts with any of the pipe names */
const startsWithPipe = (value: string): boolean => {
return PIPE_NAMES.some(p => value.startsWith(p));
};
/** Helper function to check for colon presence */
const hasNoColon = (before: string, after: string): boolean => {
return !before.includes(":") && !after.includes(":");
};
/** Helper function to extract filter name and update metadata for filter values */
const handleFilterValue = (valueBeforeCursor: string, metaData: ContextData): void => {
const [filterName, ...filterValue] = valueBeforeCursor.split(":");
metaData.contextType = ContextType.FilterValue;
metaData.filterName = filterName;
const enhanceOperators = ["=", "-", "!", "~", "<", ">", "<=", ">="] as const;
const enhanceOperator = enhanceOperators.find(op => op === filterValue[0]);
if (enhanceOperator) {
metaData.valueContext = filterValue.slice(1).join(":");
metaData.operator = `:${enhanceOperator}`;
} else {
metaData.valueContext = filterValue.join(":");
metaData.operator = ":";
}
};
/** Function to determine context type based on part type and value */
const determineContextType = (
part: LogicalPart,
valueBeforeCursor: string,
valueAfterCursor: string,
metaData: ContextData
): void => {
switch (part.type) {
case LogicalPartType.Filter:
handleFilterType(valueBeforeCursor, valueAfterCursor, metaData);
break;
case LogicalPartType.Pipe:
metaData.contextType = startsWithPipe(part.value)
? ContextType.PipeValue
: ContextType.PipeName;
break;
case LogicalPartType.FilterOrPipe:
handleFilterOrPipeType(part.value, valueBeforeCursor, metaData);
break;
}
};
/** Handle filter type context determination */
const handleFilterType = (
valueBeforeCursor: string,
valueAfterCursor: string,
metaData: ContextData
): void => {
if (hasNoColon(valueBeforeCursor, valueAfterCursor)) {
metaData.contextType = ContextType.FilterUnknown;
} else if (valueBeforeCursor.includes(":")) {
handleFilterValue(valueBeforeCursor, metaData);
} else {
metaData.contextType = ContextType.FilterName;
}
};
/** Handle FilterOrPipeType context determination */
const handleFilterOrPipeType = (
value: string,
valueBeforeCursor: string,
metaData: ContextData
): void => {
if (startsWithPipe(value)) {
metaData.contextType = ContextType.PipeValue;
} else if (valueBeforeCursor.includes(":")) {
handleFilterValue(valueBeforeCursor, metaData);
} else {
metaData.contextType = ContextType.FilterOrPipeName;
}
};

View File

@@ -2,19 +2,15 @@ export enum LogicalPartType {
Filter = "Filter",
Pipe = "Pipe",
Operator = "Operator",
FilterOrPipe = "FilterOrPipe",
}
export type LogicalPartPosition = [start: number, end: number];
export type LogicalPartSeparator = " " | "|";
export interface LogicalPart {
id: number;
value: string;
type: LogicalPartType;
position: LogicalPartPosition;
separator?: LogicalPartSeparator;
}
export interface ContextData {
@@ -23,10 +19,6 @@ export interface ContextData {
contextType: ContextType;
valueContext: string;
filterName?: string;
query?: string;
queryBeforeIncompleteFilter?: string;
separator?: LogicalPartSeparator;
operator?: ":" | ":!" | ":-" | ":=" | ":~" | ":<" | ":>" | ":<=" | ":>=";
}
export enum ContextType {
@@ -36,5 +28,4 @@ export enum ContextType {
PipeName = "Pipes",
PipeValue = "PipeValue",
Unknown = "Unknown",
FilterOrPipeName = "FilterOrPipeName",
}

View File

@@ -10,11 +10,11 @@ import { AUTOCOMPLETE_LIMITS } from "../../../../constants/queryAutocomplete";
import { LogsFiledValues } from "../../../../api/types";
import { useLogsDispatch, useLogsState } from "../../../../state/logsPanel/LogsStateContext";
import { useTenant } from "../../../../hooks/useTenant";
import { generateQuery } from "./utils";
type FetchDataArgs = {
urlSuffix: string;
setter: (value: LogsFiledValues[]) => void;
setter: Dispatch<SetStateAction<AutocompleteOptions[]>>
type: ContextType;
params?: URLSearchParams;
}
@@ -24,8 +24,7 @@ const icons = {
[ContextType.FilterValue]: <ValueIcon/>,
[ContextType.PipeName]: <FunctionIcon/>,
[ContextType.PipeValue]: <LabelIcon/>,
[ContextType.Unknown]: <ValueIcon/>,
[ContextType.FilterOrPipeName]: <FunctionIcon/>
[ContextType.Unknown]: <ValueIcon/>
};
export const useFetchLogsQLOptions = (contextData?: ContextData) => {
@@ -62,7 +61,7 @@ export const useFetchLogsQLOptions = (contextData?: ContextData) => {
}));
};
const fetchData = async ({ urlSuffix, setter, params }: FetchDataArgs) => {
const fetchData = async ({ urlSuffix, setter, type, params }: FetchDataArgs) => {
abortControllerRef.current.abort();
abortControllerRef.current = new AbortController();
const { signal } = abortControllerRef.current;
@@ -74,7 +73,7 @@ export const useFetchLogsQLOptions = (contextData?: ContextData) => {
try {
const cachedData = autocompleteCache.get(key);
if (cachedData) {
setter(cachedData);
setter(processData(cachedData, type));
setLoading(false);
return;
}
@@ -87,7 +86,7 @@ export const useFetchLogsQLOptions = (contextData?: ContextData) => {
if (response.ok) {
const data = await response.json();
const value = (data?.values || []) as LogsFiledValues[];
setter(value || []);
setter(value ? processData(value, type) : []);
dispatch({ type: "SET_AUTOCOMPLETE_CACHE", payload: { key, value } });
}
setLoading(false);
@@ -102,7 +101,7 @@ export const useFetchLogsQLOptions = (contextData?: ContextData) => {
// fetch field names
useEffect(() => {
const validContexts = [ContextType.FilterName, ContextType.FilterUnknown, ContextType.FilterOrPipeName];
const validContexts = [ContextType.FilterName, ContextType.FilterUnknown];
const isInvalidContext = !validContexts.includes(contextData?.contextType || ContextType.Unknown);
if (!serverUrl || isInvalidContext) {
return;
@@ -110,14 +109,11 @@ export const useFetchLogsQLOptions = (contextData?: ContextData) => {
setFieldNames([]);
const setter = (filterNames: LogsFiledValues[]) => {
setFieldNames(processData(filterNames, ContextType.FilterName));
};
fetchData({
urlSuffix: "field_names",
setter: setter,
params: getQueryParams({ query: contextData?.queryBeforeIncompleteFilter || "*" })
setter: setFieldNames,
type: ContextType.FilterName,
params: getQueryParams({ query: "*" })
});
return () => abortControllerRef.current?.abort();
@@ -132,14 +128,11 @@ export const useFetchLogsQLOptions = (contextData?: ContextData) => {
setFieldValues([]);
const setter = (filterValues: LogsFiledValues[]) => {
setFieldValues(processData(filterValues, ContextType.FilterValue));
};
fetchData({
urlSuffix: "field_values",
setter: setter,
params: getQueryParams({ query: generateQuery(contextData), field: contextData.filterName })
setter: setFieldValues,
type: ContextType.FilterValue,
params: getQueryParams({ query: "*", field: contextData.filterName })
});
return () => abortControllerRef.current?.abort();

View File

@@ -1,131 +0,0 @@
import { expect } from "vitest";
import { generateQuery } from "./utils";
import { ContextType } from "./types";
describe("utils", () => {
describe("_time", () => {
it("should return the trimmed value by `-`", () => {
expect(generateQuery({
queryBeforeIncompleteFilter: "_stream:{type=\"WatchEvent\"}",
contextType: ContextType.FilterValue,
filterName: "_time",
query: "_stream:{type=\"WatchEvent\"} _time:2025-04-1",
valueAfterCursor: "",
valueBeforeCursor: "_time=2025-04-1",
valueContext: "2025-04-1"
})).toStrictEqual("_stream:{type=\"WatchEvent\"} _time:2025-04");
});
it("should return the trimmed value by `:` if char `-` also exist in the query", () => {
expect(generateQuery({
queryBeforeIncompleteFilter: "_stream:{type=\"WatchEvent\"}",
contextType: ContextType.FilterValue,
filterName: "_time",
query: "_stream:{type=\"WatchEvent\"} _time:2025-04-10T23:45:5",
valueAfterCursor: "",
valueBeforeCursor: "_time=2025-04-10T23:45:5",
valueContext: "2025-04-10T23:45:5"
})).toStrictEqual("_stream:{type=\"WatchEvent\"} _time:2025-04-10T23:45");
});
it("should return default `*` instead of -time filter", () => {
expect(generateQuery({
queryBeforeIncompleteFilter: "_stream:{type=\"WatchEvent\"}",
contextType: ContextType.FilterValue,
filterName: "_time",
query: "_stream:{type=\"WatchEvent\"} _time:202",
valueAfterCursor: "",
valueBeforeCursor: "_time=202",
valueContext: "202"
})).toStrictEqual("_stream:{type=\"WatchEvent\"} *");
});
});
describe("_stream", () => {
it("should add regexp to filter value", () => {
expect(generateQuery({
queryBeforeIncompleteFilter: "",
contextType: ContextType.FilterValue,
filterName: "_stream",
query: "_stream:{type=\"WatchEve",
valueAfterCursor: "",
valueBeforeCursor: "_stream:{type=\"WatchEve",
valueContext: "{type=\"WatchEve"
})).toStrictEqual("_stream:{type=~\"WatchEve.*\"}");
});
it("should add regexp to filter value if cursor in the middle of value", () => {
expect(generateQuery({
queryBeforeIncompleteFilter: "",
contextType: ContextType.FilterValue,
filterName: "_stream",
query: "_stream:{type=\"WatchEve\"}",
valueAfterCursor: "",
valueBeforeCursor: "_stream:{type=\"WatchEve",
valueContext: "{type=\"WatchEve"
})).toStrictEqual("_stream:{type=~\"WatchEve.*\"}");
});
it("should return * if do not have value after =", () => {
expect(generateQuery({
queryBeforeIncompleteFilter: "",
contextType: ContextType.FilterValue,
filterName: "_stream",
query: "_stream:{type=",
valueAfterCursor: "",
valueBeforeCursor: "_stream:{type=",
valueContext: "{type="
})).toStrictEqual("*");
});
});
it("_msg", () => {
expect(generateQuery({
queryBeforeIncompleteFilter: "_stream:{type=\"WatchEvent\"}",
contextType: ContextType.FilterValue,
filterName: "_msg",
query: "_stream:{type=\"WatchEvent\"} _msg:453",
valueAfterCursor: "",
valueBeforeCursor: "_msg:453",
valueContext: "453"
})).toStrictEqual("_stream:{type=\"WatchEvent\"} *");
});
it("_stream_id", () => {
expect(generateQuery({
queryBeforeIncompleteFilter: "_stream:{type=\"WatchEvent\"}",
contextType: ContextType.FilterValue,
filterName: "_stream_id",
query: "_stream:{type=\"WatchEvent\"} _stream_id:453",
valueAfterCursor: "",
valueBeforeCursor: "_stream_id:453",
valueContext: "453"
})).toStrictEqual("_stream:{type=\"WatchEvent\"} *");
});
describe("other fields", () => {
it("should add prefix filter to other type of field names", () => {
expect(generateQuery({
queryBeforeIncompleteFilter: "",
contextType: ContextType.FilterValue,
filterName: "repo.name",
query: "repo.name:Victori",
valueAfterCursor: "",
valueBeforeCursor: "repo.name:Victori",
valueContext: "Victori"
})).toStrictEqual("repo.name:Victori*");
});
it("should add prefix filter to other type of field names with escaped via double quote", () => {
expect(generateQuery({
queryBeforeIncompleteFilter: "",
contextType: ContextType.FilterValue,
filterName: "repo.name",
query: "repo.name:\"Victori",
valueAfterCursor: "",
valueBeforeCursor: "repo.name:\"Victori",
valueContext: "Victori"
})).toStrictEqual("repo.name:Victori*");
});
});
});

View File

@@ -1,61 +0,0 @@
import { ContextData } from "./types";
const getStreamFieldQuery = (valueContext: string) => {
if (valueContext.includes("=")) {
const [fieldName, fieldValue] = valueContext.split("=");
if (fieldValue) {
return `_stream:${fieldName}=~${fieldValue}.*"}`;
}
}
return "*";
};
const getLastPartUntilDelimiter = (value: string, delimiter: string) => {
const lastIndexOfDelimiter = value.lastIndexOf(delimiter);
return lastIndexOfDelimiter !== -1 ? value.slice(0, lastIndexOfDelimiter) : "";
};
const getDateQuery = (contextData: ContextData) => {
let fieldValue = "";
if (contextData.valueContext.includes(":")) {
fieldValue = getLastPartUntilDelimiter(contextData.valueContext, ":");
} else if (contextData.valueContext.includes("-")) {
fieldValue = getLastPartUntilDelimiter(contextData.valueContext, "-");
}
return fieldValue ? `${contextData.filterName}:${fieldValue}` : "*";
};
/**
* Generates a query string based on the provided context data.
*
* The function processes the input based on the `filterName` property:
*
* - If `filterName` is `_msg` or `_stream_id`, the query cannot be generated specifically,
* so a wildcard query (`"*"`) is returned.
*
* - If `filterName` is `_stream`, the query is generated using regexp (`{type=~"value.*"}`).
*
* - If `filterName` is `_time`, a simplified query is created by trimming the value up
* to the first occurrence of a delimiter such as `-` or `:`.
*
* - For all other values of `filterName`, a prefix query is returned using
* the `query` value with a `*` appended (e.g., `"value*"`).
*
* @param {ContextData} contextData - The context object containing query parameters and metadata.
* @returns {string} The generated query string.
*/
export const generateQuery = (contextData: ContextData): string => {
let fieldQuery = "";
if (!contextData.filterName || !contextData.query || ["_msg", "_stream_id"].includes(contextData.filterName)) {
fieldQuery = "*";
} else if ("_stream" === contextData.filterName) {
fieldQuery = getStreamFieldQuery(contextData.valueContext);
} else if ("_time" === contextData.filterName) {
fieldQuery = getDateQuery(contextData);
} else {
fieldQuery = `${contextData.filterName}:${contextData.valueContext}*`;
}
return contextData.queryBeforeIncompleteFilter ? `${contextData.queryBeforeIncompleteFilter}${contextData.separator ?? " "}${fieldQuery}` : fieldQuery;
};

View File

@@ -1,43 +0,0 @@
import { hasSortPipe } from "./sort";
describe("hasSortPipe()", () => {
/** Queries that MUST be recognised as containing a sort/order pipe. */
const positive: string[] = [
// ───── basic usage ─────
"sort by (_time)",
"| sort by (_time)",
"|sort(_time) desc",
"| order by (foo desc)",
"_time:5m | sort by (_stream, _time)",
// ───── documented options ─────
"_time:1h | sort by (request_duration desc) limit 10",
"_time:1h | sort by (request_duration desc) partition by (host) limit 3",
"_time:5m | sort by (_time) rank as position",
// ───── whitespace / tabs ─────
"|\t sort\tby (host)",
// ───── no space after the pipe ─────
"foo|sort by (_time)",
];
/** Queries that MUST **not** be recognised (false positives). */
const negative: string[] = [
"", // empty
"error | sample 100", // no sort
"|sorted(field)", // 'sorted' ≠ 'sort'
"|sorter(field)", // 'sorter' ≠ 'sort'
"my_sort(field)", // function name
"| sorta by (field)", // 'sorta'
"foo | orderliness by (bar)", // 'orderliness' ≠ 'order'
];
it.each(positive)("detects pipe in ➜ %s", query => {
expect(hasSortPipe(query)).toBe(true);
});
it.each(negative)("does NOT detect pipe in ➜ %s", query => {
expect(hasSortPipe(query)).toBe(false);
});
});

View File

@@ -1,5 +0,0 @@
const hasSortPipeRe = /(?:^|\|)\s*(?:sort|order)\b/i;
export function hasSortPipe(query: string): boolean {
return hasSortPipeRe.test(query);
}

View File

@@ -1,4 +1,4 @@
import { FC, useMemo, useState } from "preact/compat";
import React, { FC, useMemo, useState } from "preact/compat";
import useBoolean from "../../../hooks/useBoolean";
import { RestartIcon, SettingsIcon } from "../../Main/Icons";
import Button from "../../Main/Button/Button";
@@ -19,8 +19,8 @@ import {
LOGS_URL_PARAMS,
WITHOUT_GROUPING
} from "../../../constants/logs";
import { getFromStorage, saveToStorage } from "../../../utils/storage";
import LogParsingSwitches from "../../Configurators/LogsSettings/LogParsingSwitches";
import { useLocalStorageBoolean } from "../../../hooks/useLocalStorageBoolean";
const {
GROUP_BY,
@@ -48,7 +48,7 @@ const GroupLogsConfigurators: FC<Props> = ({ logs }) => {
const [dateFormat, setDateFormat] = useState(searchParams.get(DATE_FORMAT) || LOGS_DATE_FORMAT);
const [errorFormat, setErrorFormat] = useState("");
const [disabledHovers, handleSetDisabledHovers] = useLocalStorageBoolean("LOGS_DISABLED_HOVERS");
const [disabledHovers, setDisabledHovers] = useState(!!getFromStorage("LOGS_DISABLED_HOVERS"));
const isGroupChanged = groupBy !== LOGS_GROUP_BY;
const isDisplayFieldsChanged = displayFields.length !== 1 || displayFields[0] !== LOGS_DISPLAY_FIELDS;
@@ -62,8 +62,7 @@ const GroupLogsConfigurators: FC<Props> = ({ logs }) => {
].some(Boolean);
const logsKeys = useMemo(() => {
const uniqueKeys = new Set(logs.map(l => Object.keys(l)).flat());
return Array.from(uniqueKeys).sort((a, b) => a.localeCompare(b));
return Array.from(new Set(logs.map(l => Object.keys(l)).flat()));
}, [logs]);
const {
@@ -117,6 +116,11 @@ const GroupLogsConfigurators: FC<Props> = ({ logs }) => {
handleClose();
};
const handleSetDisabledHovers = (value: boolean) => {
setDisabledHovers(value);
saveToStorage("LOGS_DISABLED_HOVERS", value);
};
const tooltipContent = () => {
if (!hasChanges) return title;
return (

View File

@@ -1,3 +1,4 @@
import React from "react";
import { getCssVariable } from "../../../utils/theme";
export const LogoIcon = () => (
@@ -642,17 +643,3 @@ export const PauseIcon = () => (
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
);
export const ScrollToTopIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M8 12l4-4 4 4m-4-4v12"
strokeWidth="2"
stroke="currentColor"
fill="none"
/>
</svg>
);

View File

@@ -1,59 +0,0 @@
import { FC, useEffect, useState } from "preact/compat";
import Button from "../Main/Button/Button";
import Tooltip from "../Main/Tooltip/Tooltip";
import { ScrollToTopIcon } from "../Main/Icons";
import classNames from "classnames";
import "./style.scss";
import { useCallback } from "react";
interface ScrollToTopButtonProps {
className?: string;
}
const ScrollToTopButton: FC<ScrollToTopButtonProps> = ({ className }) => {
const [isVisible, setIsVisible] = useState(false);
const checkScrollPosition = () => {
const scrollPosition = window.pageYOffset || document.documentElement.scrollTop;
const visibleHeightThreshold = window.innerHeight;
setIsVisible(scrollPosition > visibleHeightThreshold);
};
const scrollToTop = useCallback(() => {
window.scrollTo({
top: 0,
behavior: "smooth"
});
}, []);
useEffect(() => {
window.addEventListener("scroll", checkScrollPosition);
checkScrollPosition();
return () => {
window.removeEventListener("scroll", checkScrollPosition);
};
}, []);
return (
<div
className={classNames({
"vm-scroll-to-top-button": true,
"vm-scroll-to-top-button_visible": isVisible
}, className)}
>
<Tooltip title="Scroll to top">
<Button
variant="contained"
color="primary"
onClick={scrollToTop}
ariaLabel="Scroll to top"
startIcon={<ScrollToTopIcon />}
/>
</Tooltip>
</div>
);
};
export default ScrollToTopButton;

View File

@@ -1,26 +0,0 @@
@use "src/styles/variables" as *;
.vm-scroll-to-top-button {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 4;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s, visibility 0.3s;
&_visible {
opacity: 1;
visibility: visible;
}
.vm-button {
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
}

View File

@@ -1,4 +1,4 @@
import { FC, useEffect, useRef, useMemo } from "preact/compat";
import React, { FC, useEffect, useRef, useMemo } from "preact/compat";
import Button from "../../Main/Button/Button";
import { SearchIcon, SettingsIcon } from "../../Main/Icons";
import "./style.scss";
@@ -49,8 +49,8 @@ const TableSettings: FC<TableSettingsProps> = ({
const filteredColumns = useMemo(() => {
const allColumns = customColumns.concat(columns);
const result = searchColumn ? allColumns.filter(col => col.includes(searchColumn)) : allColumns;
return result.sort((a, b) => a.localeCompare(b));
if (!searchColumn) return allColumns;
return allColumns.filter(col => col.includes(searchColumn));
}, [columns, customColumns, searchColumn]);
const isAllChecked = useMemo(() => {

View File

@@ -1,70 +0,0 @@
import { act, renderHook } from "@testing-library/preact";
import { useLocalStorageBoolean } from "./useLocalStorageBoolean";
import * as storageUtils from "../utils/storage";
import { Mock } from "vitest";
import { StorageKeys } from "../utils/storage";
vi.mock("../utils/storage");
const testStorageKey = "TEST_STORAGE_KEY" as StorageKeys;
describe("useLocalStorageBoolean", () => {
const { getFromStorage, saveToStorage } = storageUtils;
beforeEach(() => {
vi.clearAllMocks();
});
it("initializes with the value from localStorage", () => {
const mockGetFromStorage = getFromStorage as Mock;
mockGetFromStorage.mockReturnValueOnce(true);
const { result } = renderHook(() => useLocalStorageBoolean(testStorageKey));
expect(result.current[0]).toBe(true);
expect(getFromStorage).toHaveBeenCalledWith(testStorageKey);
});
it("updates localStorage and state when setter is called", () => {
const mockGetFromStorage = getFromStorage as Mock;
mockGetFromStorage.mockReturnValueOnce(false);
const { result } = renderHook(() => useLocalStorageBoolean(testStorageKey));
act(() => {
result.current[1](true);
});
expect(saveToStorage).toHaveBeenCalledWith(testStorageKey, true);
expect(result.current[0]).toBe(false);
});
it("reacts to changes in localStorage by storage events", () => {
const mockGetFromStorage = getFromStorage as Mock;
mockGetFromStorage.mockReturnValueOnce(false);
const { result } = renderHook(() => useLocalStorageBoolean(testStorageKey));
// Simulate a storage event
act(() => {
mockGetFromStorage.mockReturnValueOnce(true);
window.dispatchEvent(new StorageEvent("storage", { key: testStorageKey, newValue: "true" }));
});
expect(result.current[0]).toBe(true);
});
it("does not update state if the localStorage value remains the same", () => {
const mockGetFromStorage = getFromStorage as Mock;
mockGetFromStorage.mockReturnValueOnce(false);
const { result } = renderHook(() => useLocalStorageBoolean(testStorageKey));
act(() => {
mockGetFromStorage.mockReturnValueOnce(false);
window.dispatchEvent(new StorageEvent("storage", { key: testStorageKey, newValue: "false" }));
});
expect(result.current[0]).toBe(false);
});
});

View File

@@ -1,31 +0,0 @@
import { useMemo, useState } from "preact/compat";
import { getFromStorage, saveToStorage, StorageKeys } from "../utils/storage";
import useEventListener from "./useEventListener";
import { useCallback } from "react";
/**
* A custom hook that synchronizes a boolean state with a value stored in localStorage.
*
* @param {StorageKeys} key - The key used to access the corresponding value in localStorage.
* @returns {[boolean, function]} A tuple containing the current boolean value from localStorage and a setter function to update the value in localStorage.
*
* The hook listens to the "storage" event to automatically update the state when the localStorage value changes.
*/
export const useLocalStorageBoolean = (key: StorageKeys): [boolean, (value: boolean) => void] => {
const [value, setValue] = useState(!!getFromStorage(key));
const handleUpdateStorage = useCallback(() => {
const newValue = !!getFromStorage(key);
if (newValue !== value) {
setValue(newValue);
}
}, [key, value]);
const setNewValue = useCallback((newValue: boolean) => {
saveToStorage(key, newValue);
}, [key]);
useEventListener("storage", handleUpdateStorage);
return useMemo(() => [value, setNewValue], [value, setNewValue]);
};

View File

@@ -1,4 +1,4 @@
import { FC, useEffect, useMemo, useState } from "preact/compat";
import React, { FC, useEffect, useMemo, useState } from "preact/compat";
import ExploreLogsBody from "./ExploreLogsBody/ExploreLogsBody";
import useStateSearchParams from "../../hooks/useStateSearchParams";
import useSearchParamsFromObject from "../../hooks/useSearchParamsFromObject";
@@ -18,7 +18,6 @@ import { useSearchParams } from "react-router-dom";
import { useQueryDispatch, useQueryState } from "../../state/query/QueryStateContext";
import { getUpdatedHistory } from "../../components/QueryHistory/utils";
import { useDebounceCallback } from "../../hooks/useDebounceCallback";
import usePrevious from "../../hooks/usePrevious";
const storageLimit = Number(getFromStorage("LOGS_LIMIT"));
const defaultLimit = isNaN(storageLimit) ? LOGS_ENTRIES_LIMIT : storageLimit;
@@ -31,7 +30,6 @@ const ExploreLogs: FC = () => {
const { setSearchParamsFromKeys } = useSearchParamsFromObject();
const [searchParams] = useSearchParams();
const hideChart = useMemo(() => searchParams.get("hide_chart"), [searchParams]);
const prevHideChart = usePrevious(hideChart);
const [limit, setLimit] = useStateSearchParams(defaultLimit, "limit");
const [query, setQuery] = useStateSearchParams("*", "query");
@@ -120,10 +118,11 @@ const ExploreLogs: FC = () => {
}, [query, isUpdatingQuery]);
useEffect(() => {
if (!hideChart && prevHideChart) {
fetchLogHits(period);
}
}, [hideChart, prevHideChart, period]);
if (!hideChart) debouncedFetchLogs(period, true);
return () => {
debouncedFetchLogs.cancel?.();
};
}, [hideChart, period]);
return (
<div className="vm-explore-logs">

View File

@@ -19,8 +19,8 @@ interface LiveTailingSettingsProps {
handleResumeLiveTailing: () => void;
pauseLiveTailing: () => void;
clearLogs: () => void;
isRawJsonView: boolean;
onRawJsonViewChange: (value: boolean) => void;
isCompactTailingNumber: boolean;
handleSetCompactTailing: (value: boolean) => void;
}
const LiveTailingSettings: FC<LiveTailingSettingsProps> = ({
@@ -32,8 +32,8 @@ const LiveTailingSettings: FC<LiveTailingSettingsProps> = ({
handleResumeLiveTailing,
pauseLiveTailing,
clearLogs,
isRawJsonView,
onRawJsonViewChange
isCompactTailingNumber,
handleSetCompactTailing
}) => {
const settingButtonRef = useRef<HTMLDivElement>(null);
const { value: isSettingsOpen, setFalse: closeSettings, setTrue: openSettings } = useBoolean(false);
@@ -106,12 +106,12 @@ const LiveTailingSettings: FC<LiveTailingSettingsProps> = ({
<div className="vm-live-tailing-view__settings-modal">
<div className={"vm-live-tailing-view__settings-modal-item"}>
<Switch
label={"Raw JSON View"}
value={isRawJsonView}
onChange={onRawJsonViewChange}
label={"Expandable Properties View"}
value={isCompactTailingNumber}
onChange={handleSetCompactTailing}
/>
<span className="vm-group-logs-configurator-item__info">
When this option is enabled, logs will be displayed in raw JSON format. This improves performance and uses less CPU and memory.
Switches log display to expandable properties view with additional visualization settings. Please note: when processing large volumes of data, it may increase system response time.
</span>
</div>
</div>

View File

@@ -10,15 +10,11 @@ import { useSearchParams } from "react-router-dom";
import throttle from "lodash/throttle";
import GroupLogsItem from "../../../GroupLogs/GroupLogsItem";
import LiveTailingSettings from "./LiveTailingSettings";
import Alert from "../../../../../components/Main/Alert/Alert";
import { isDecreasing } from "../../../../../utils/array";
import { useLocalStorageBoolean } from "../../../../../hooks/useLocalStorageBoolean";
import ScrollToTopButton from "../../../../../components/ScrollToTopButton/ScrollToTopButton";
const SCROLL_THRESHOLD = 100;
const scrollToBottom = () => window.scrollTo({
top: document.documentElement.scrollHeight,
behavior: "smooth"
behavior: "instant"
});
const throttledScrollToBottom = throttle(scrollToBottom, 200);
@@ -30,7 +26,8 @@ const LiveTailingView: FC<ViewProps> = ({ settingsRef }) => {
const { setSearchParamsFromKeys } = useSearchParamsFromObject();
const [rowsPerPage, setRowsPerPage] = useStateSearchParams(100, "rows_per_page");
const [query, _setQuery] = useStateSearchParams("*", "query");
const [isRawJsonView, setIsRawJsonView] = useLocalStorageBoolean("RAW_JSON_LIVE_VIEW");
const [isCompactTailingStr] = useStateSearchParams(0, "compact_tailing");
const isCompactTailingNumber = Boolean(Number(isCompactTailingStr));
const {
logs,
isPaused,
@@ -39,8 +36,7 @@ const LiveTailingView: FC<ViewProps> = ({ settingsRef }) => {
stopLiveTailing,
pauseLiveTailing,
resumeLiveTailing,
clearLogs,
isLimitedLogsPerUpdate
clearLogs
} = useLiveTailingLogs(query, rowsPerPage);
const displayFieldsString = searchParams.get(LOGS_URL_PARAMS.DISPLAY_FIELDS) || LOGS_DISPLAY_FIELDS;
@@ -55,6 +51,10 @@ const LiveTailingView: FC<ViewProps> = ({ settingsRef }) => {
setSearchParamsFromKeys({ rows_per_page: limit });
}, [setRowsPerPage, setSearchParamsFromKeys]);
const handleSetCompactTailing = useCallback((value: boolean) => {
setSearchParamsFromKeys({ compact_tailing: Number(value) });
}, [setSearchParamsFromKeys]);
useEffect(() => {
startLiveTailing();
return () => stopLiveTailing();
@@ -64,17 +64,13 @@ const LiveTailingView: FC<ViewProps> = ({ settingsRef }) => {
const container = containerRef.current;
if (!container) return;
let prevScrollTop: number[] = [];
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
const isBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < SCROLL_THRESHOLD;
setIsAtBottom(isBottom);
prevScrollTop.push(scrollTop);
prevScrollTop = prevScrollTop.slice(-3);
const isMoveToTop = isDecreasing(prevScrollTop);
if (!isBottom && !isPaused && isMoveToTop) {
if (!isBottom && !isPaused) {
pauseLiveTailing();
}
};
@@ -93,6 +89,8 @@ const LiveTailingView: FC<ViewProps> = ({ settingsRef }) => {
handleResumeLiveTailing();
}, [rowsPerPage]);
if (error) {
return <div className="vm-live-tailing-view__error">{error}</div>;
}
@@ -108,10 +106,9 @@ const LiveTailingView: FC<ViewProps> = ({ settingsRef }) => {
handleResumeLiveTailing={handleResumeLiveTailing}
pauseLiveTailing={pauseLiveTailing}
clearLogs={clearLogs}
isRawJsonView={isRawJsonView}
onRawJsonViewChange={setIsRawJsonView}
isCompactTailingNumber={isCompactTailingNumber}
handleSetCompactTailing={handleSetCompactTailing}
/>
<ScrollToTopButton />
<div
ref={containerRef}
className="vm-live-tailing-view__container"
@@ -120,31 +117,27 @@ const LiveTailingView: FC<ViewProps> = ({ settingsRef }) => {
? (<div className="vm-live-tailing-view__empty">Waiting for logs...</div>)
: (<div className="vm-live-tailing-view__logs">
{logs.map(({ _log_id, ...log }, idx) =>
isRawJsonView ? (
<pre
key={idx}
className="vm-live-tailing-view__log-row"
onMouseDown={pauseLiveTailing}
>
{JSON.stringify(log)}
</pre>
) : (
<GroupLogsItem
key={_log_id}
log={log}
onItemClick={pauseLiveTailing}
hideGroupButton={true}
displayFields={displayFields}
/>
)
isCompactTailingNumber
? (
<GroupLogsItem
key={_log_id}
log={log}
onItemClick={pauseLiveTailing}
hideGroupButton={true}
displayFields={displayFields}
/>
) : (
<pre
key={idx}
className="vm-live-tailing-view__log-row"
>
{JSON.stringify(log)}
</pre>
)
)}
</div>
)}
</div>
{isLimitedLogsPerUpdate && (
<Alert variant="warning">Too many logs per second detected. Large volumes of log data are difficult to process
and may impact performance. We recommend adding filters to your query for better analysis and system
performance.</Alert>)}
</>
);
};

View File

@@ -34,10 +34,9 @@
width: 100%;
height: 100%;
overflow: auto;
min-height: calc(100vh - 120px);
padding: $padding-global;
min-height: 200px;
font-family: $font-family-monospace;
padding-bottom: $padding-medium;
transition: min-height 0.3s ease;
}
&__empty {

View File

@@ -1,147 +0,0 @@
import { act, renderHook } from "@testing-library/preact";
import { useLiveTailingLogs } from "./useLiveTailingLogs";
import { vi } from "vitest";
vi.mock("../../../../../state/common/StateContext", () => ({
useAppState: () => ({ serverUrl: "http://localhost:8080" }),
}));
vi.mock("../../../../../hooks/useTenant", () => ({
useTenant: () => ({}),
}));
// Mock dependencies
const mockFetch = vi.fn();
global.fetch = mockFetch;
const createMockStreamResponse = (logs: string[], sendCount: number = 1) => ({
ok: true,
body: new ReadableStream({
async start(controller) {
for (let i = 0; i < sendCount; i++) {
logs.forEach((log) => {
controller.enqueue(new TextEncoder().encode(log + "\n"));
});
await new Promise((resolve) => setTimeout(resolve, 1000));
}
controller.close();
},
}),
text: async () => logs.join("\n"),
});
describe("useLiveTailingLogs", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
it("should start live tailing and process logs", async () => {
const query = "*";
const limit = 10;
const { result } = renderHook(() => useLiveTailingLogs(query, limit));
mockFetch.mockResolvedValue(createMockStreamResponse(["{\"logs\":\"test log\"}"]));
await act(async () => {
const started = await result.current.startLiveTailing();
expect(started).toBe(true);
});
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8080/select/logsql/tail",
expect.objectContaining({
method: "POST",
body: new URLSearchParams({
query: query.trim(),
}),
})
);
});
it("should pause and resume live tailing", () => {
const query = "*";
const limit = 10;
const { result } = renderHook(() => useLiveTailingLogs(query, limit));
act(() => {
result.current.pauseLiveTailing();
});
expect(result.current.isPaused).toBe(true);
act(() => {
result.current.resumeLiveTailing();
});
expect(result.current.isPaused).toBe(false);
});
it("should stop live tailing", async () => {
const query = "*";
const limit = 10;
const { result } = renderHook(() => useLiveTailingLogs(query, limit));
act(() => {
result.current.stopLiveTailing();
});
expect(result.current.logs).toHaveLength(0);
});
it("should clear logs", () => {
const query = "*";
const limit = 10;
const { result } = renderHook(() => useLiveTailingLogs(query, limit));
act(() => {
result.current.clearLogs();
});
expect(result.current.logs).toEqual([]);
});
it("should handle errors during live tailing", async () => {
const query = "*";
const limit = 10;
const { result } = renderHook(() => useLiveTailingLogs(query, limit));
mockFetch.mockRejectedValue(new Error("Network error"));
await act(async () => {
const started = await result.current.startLiveTailing();
expect(started).toBe(false);
});
expect(result.current.error).toBe("Error: Network error");
expect(result.current.logs).toHaveLength(0);
});
it("should process high load of logs incoming at 100k logs per second", async () => {
const query = "*";
const limit = 1000;
const logCount = 10000; // High log rate
const logs = Array.from({ length: logCount }, (_, i) => `{"log": "log message ${i}"}`);
const { result } = renderHook(() => useLiveTailingLogs(query, limit));
mockFetch.mockResolvedValue(createMockStreamResponse(logs, 7));
await act(async () => {
const started = await result.current.startLiveTailing();
expect(started).toBe(true);
});
// Wait for logs to process
await new Promise((resolve) => setTimeout(resolve, 7000));
// Verify logs are limited and processed correctly
expect(result.current.logs.length).toBeLessThanOrEqual(limit);
// After setting flag isLimitedLogsPerUpdate when more than 200 logs received 5 times in a row,
// we take only the last 200 logs, so we get 800 older logs (9200 - 9999) and 200 new logs (9800-9999)
expect(result.current.logs[0].log).toStrictEqual("log message 9200");
expect(result.current.logs[799].log).toStrictEqual("log message 9999");
expect(result.current.isLimitedLogsPerUpdate).toBeTruthy();
}, { timeout: 9000 });
});

View File

@@ -2,119 +2,22 @@ import { useCallback, useEffect, useRef, useState } from "preact/compat";
import { ErrorTypes } from "../../../../../types";
import { Logs } from "../../../../../api/types";
import { useAppState } from "../../../../../state/common/StateContext";
import { useSearchParams } from "react-router-dom";
import useBoolean from "../../../../../hooks/useBoolean";
import { useTenant } from "../../../../../hooks/useTenant";
import { LogFlowAnalyzer } from "./utils";
/**
* Defines the log's threshold, after which will be shown a warning notification
*/
const LOGS_THRESHOLD = 200;
const CONNECTION_TIMEOUT_MS = 5000;
const PROCESSING_INTERVAL_MS = 1000;
const createStreamProcessor = (
bufferRef: React.MutableRefObject<string>,
bufferLinesRef: React.MutableRefObject<string[]>,
setError: (error: string) => void,
restartTailing: () => Promise<boolean>
) => {
return async (reader: ReadableStreamDefaultReader<Uint8Array>) => {
let lastDataTime = Date.now();
const connectionCheckInterval = setInterval(() => {
const timeSinceLastData = Date.now() - lastDataTime;
if (timeSinceLastData > CONNECTION_TIMEOUT_MS) {
clearInterval(connectionCheckInterval);
restartTailing();
return;
}
}, CONNECTION_TIMEOUT_MS);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
lastDataTime = Date.now();
const chunk = new TextDecoder().decode(value);
const lines = (bufferRef.current + chunk).split("\n");
bufferRef.current = lines.pop() || "";
bufferLinesRef.current = [...bufferLinesRef.current, ...lines];
}
} catch (e) {
if (e instanceof Error && e.name !== "AbortError") {
console.error("Stream processing error:", e);
restartTailing();
}
} finally {
clearInterval(connectionCheckInterval);
}
};
};
const parseLogLines = (lines: string[], counterRef: React.MutableRefObject<bigint>): Logs[] => {
return lines
.map(line => {
try {
const parsedLine = line && JSON.parse(line);
parsedLine._log_id = counterRef.current++;
return parsedLine;
} catch (e) {
console.error(`Failed to parse "${line}" to JSON\n`, e);
return null;
}
})
.filter(Boolean) as Logs[];
};
interface ProcessBufferedLogsParams {
lines: string[];
limit: number;
counterRef: React.MutableRefObject<bigint>;
setIsLimitedLogsPerUpdate: (isLimited: boolean) => void;
setLogs: React.Dispatch<React.SetStateAction<Logs[]>>;
bufferLinesRef: React.MutableRefObject<string[]>;
logFlowAnalyzerRef?: React.MutableRefObject<LogFlowAnalyzer>;
}
const processBufferedLogs = ({
lines,
limit,
counterRef,
setIsLimitedLogsPerUpdate,
setLogs,
bufferLinesRef,
logFlowAnalyzerRef
}: ProcessBufferedLogsParams) => {
const isLimitLogsMode = logFlowAnalyzerRef?.current?.update(lines.length) === "high";
const limitedLines = isLimitLogsMode && lines.length > LOGS_THRESHOLD ? lines.slice(-LOGS_THRESHOLD) : lines;
const newLogs = parseLogLines(limitedLines, counterRef);
setIsLimitedLogsPerUpdate(isLimitLogsMode);
setLogs(prevLogs => {
const combinedLogs = [...prevLogs, ...newLogs];
return combinedLogs.length > limit ? combinedLogs.slice(-limit) : combinedLogs;
});
bufferLinesRef.current = [];
};
export const useLiveTailingLogs = (query: string, limit: number) => {
const { serverUrl } = useAppState();
const [searchParams] = useSearchParams();
const [logs, setLogs] = useState<Logs[]>([]);
const { value: isPaused, setTrue: pauseLiveTailing, setFalse: resumeLiveTailing } = useBoolean(false);
const tenant = useTenant();
const [error, setError] = useState<ErrorTypes | string>();
const [isLimitedLogsPerUpdate, setIsLimitedLogsPerUpdate] = useState(false);
const counterRef = useRef<bigint>(0n);
const abortControllerRef = useRef(new AbortController());
const readerRef = useRef<ReadableStreamDefaultReader<Uint8Array> | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const bufferRef = useRef<string>("");
const bufferLinesRef = useRef<string[]>([]);
const logFlowAnalyzerRef = useRef(new LogFlowAnalyzer());
const stopLiveTailing = useCallback(() => {
if (readerRef.current) {
@@ -137,8 +40,13 @@ export const useLiveTailingLogs = (query: string, limit: number) => {
const { signal } = abortControllerRef.current;
setError(undefined);
setLogs([]);
try {
const tenant = {
AccountID: searchParams.get("accountID") || "0",
ProjectID: searchParams.get("projectID") || "0"
};
const response = await fetch(`${serverUrl}/select/logsql/tail`, {
signal,
method: "POST",
@@ -160,14 +68,25 @@ export const useLiveTailingLogs = (query: string, limit: number) => {
const reader = response.body.getReader();
readerRef.current = reader;
const processStream = createStreamProcessor(
bufferRef,
bufferLinesRef,
setError,
startLiveTailing
);
const processStream = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
processStream(reader);
// Convert the Uint8Array to a string
const chunk = new TextDecoder().decode(value);
bufferRef.current += chunk;
}
} catch (e) {
if (e instanceof Error && e.name !== "AbortError") {
console.error("Stream processing error:", e);
setError(String(e));
}
}
};
processStream();
return true;
} catch (e) {
if (e instanceof Error && e.name !== "AbortError") {
@@ -179,33 +98,42 @@ export const useLiveTailingLogs = (query: string, limit: number) => {
}
}, [query, stopLiveTailing]);
useEffect(() => {
if (isPaused) {
const pauseTimerId = setInterval(() => {
if (bufferLinesRef.current.length > limit) {
bufferLinesRef.current = bufferLinesRef.current.slice(-limit);
}
}, PROCESSING_INTERVAL_MS);
return () => {
clearInterval(pauseTimerId);
};
}
if (isPaused) return;
/**
* Process incoming log data at a throttled rate (every 1s)
* This interval-based approach prevents CPU overload by:
* 1. Batching log processing instead of processing each chunk immediately
* 2. Limiting UI updates to a reasonable frequency (1/sec) even when data streams in rapidly
* 3. Reducing performance impact when handling large volumes of incoming logs
* 4. Allowing efficient garbage collection between processing cycles
*/
const timerId = setInterval(() => {
const lines = bufferLinesRef.current;
processBufferedLogs({
lines,
limit,
counterRef,
setIsLimitedLogsPerUpdate,
setLogs,
bufferLinesRef,
logFlowAnalyzerRef
});
}, PROCESSING_INTERVAL_MS);
const lines = bufferRef.current.split("\n");
bufferRef.current = lines.pop() || "";
const newLogs = lines
.map(line => {
try {
const parsedLine = line && JSON.parse(line);
parsedLine._log_id = counterRef.current++;
return parsedLine;
} catch (e) {
console.error(`Failed to parse "${line}" to JSON\n`, e);
return null;
}
})
.filter(Boolean) as Logs[];
setLogs(prevLogs => {
const combinedLogs = [...prevLogs, ...newLogs];
return combinedLogs.length > limit ? combinedLogs.slice(-limit) : combinedLogs;
});
}, 1000);
return () => clearInterval(timerId);
}, [limit, isPaused, isLimitedLogsPerUpdate]);
}, [limit, isPaused]);
const clearLogs = useCallback(() => {
setLogs([]);
@@ -219,7 +147,6 @@ export const useLiveTailingLogs = (query: string, limit: number) => {
stopLiveTailing,
pauseLiveTailing,
resumeLiveTailing,
clearLogs,
isLimitedLogsPerUpdate
clearLogs
};
};
};

View File

@@ -1,45 +0,0 @@
export class LogFlowAnalyzer {
private threshold: number;
private windowSize: number;
private minHighCount: number;
private minNormalCount: number;
private window: number[];
private state: "normal" | "high";
/**
* @param {number} threshold - The threshold value used for state evaluation. Defaults to 200.
* @param {number} windowSize - The size of the window used for tracking data. Defaults to 10.
* @param {number} minHighCount - The minimum number of high occurrences needed for state transition. Defaults to 6.
* @param {number} minNormalCount - The minimum number of normal occurrences needed for state reset. Defaults to 2.
* @return {void}
*/
constructor(threshold: number = 200, windowSize: number = 10, minHighCount: number = 6, minNormalCount: number = 2) {
this.threshold = threshold;
this.windowSize = windowSize;
this.minHighCount = minHighCount;
this.minNormalCount = minNormalCount;
this.window = [];
this.state = "normal";
}
update(logCount: number): "normal" | "high" {
this.window.push(logCount);
if (this.window.length > this.windowSize) {
this.window.shift();
}
const highCount = this.window.filter((x) => x > this.threshold).length;
if (this.state === "normal") {
if (highCount >= this.minHighCount) {
this.state = "high";
}
} else if (this.state === "high") {
if (highCount < this.minNormalCount) {
this.state = "normal";
}
}
return this.state;
}
}

View File

@@ -27,7 +27,7 @@ const TableView: FC<ViewProps> = ({ data, settingsRef }) => {
keys.add(key);
}
}
return Array.from(keys).sort((a,b) => a.localeCompare(b));
return Array.from(keys);
}, [data]);
const handleSetRowsPerPage = (limit: number) => {
@@ -77,4 +77,4 @@ const TableView: FC<ViewProps> = ({ data, settingsRef }) => {
);
};
export default TableView;
export default TableView;

View File

@@ -17,9 +17,9 @@ import Pagination from "../../../components/Main/Pagination/Pagination";
import SelectLimit from "../../../components/Main/Pagination/SelectLimit/SelectLimit";
import { usePaginateGroups } from "../hooks/usePaginateGroups";
import { GroupLogsType } from "../../../types";
import { getNanoTimestamp } from "../../../utils/time";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import DownloadLogsButton from "../DownloadLogsButton/DownloadLogsButton";
import { hasSortPipe } from "../../../components/Configurators/QueryEditor/LogsQL/utils/sort";
interface Props {
logs: Logs[];
@@ -30,9 +30,6 @@ const GroupLogs: FC<Props> = ({ logs, settingsRef }) => {
const { isMobile } = useDeviceDetect();
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get("query") || "";
const queryHasSort = hasSortPipe(query);
const [page, setPage] = useState(1);
const [expandGroups, setExpandGroups] = useState<boolean[]>([]);
@@ -50,10 +47,15 @@ const GroupLogs: FC<Props> = ({ logs, settingsRef }) => {
const streamValue = item.values[0]?.[groupBy] || "";
const pairs = getStreamPairs(streamValue);
// VictoriaLogs sends rows oldest → newest when the query has no `| sort` pipe,
// so we reverse the array to put the newest entries first.
// If a sort is already specified, keep the original order.
const values = queryHasSort ? item.values : item.values.toReversed();
// values sorting by time
const values = item.values.sort((a, b) => {
const aTimestamp = getNanoTimestamp(a._time);
const bTimestamp = getNanoTimestamp(b._time);
if (aTimestamp < bTimestamp) return 1;
if (aTimestamp > bTimestamp) return -1;
return 0;
});
return {
keys: item.keys,
@@ -62,8 +64,8 @@ const GroupLogs: FC<Props> = ({ logs, settingsRef }) => {
pairs,
total: values.length,
};
}).sort((a, b) => b.total - a.total); // groups sorting
}, [logs, groupBy, queryHasSort]);
}).sort((a, b) => b.values.length - a.values.length); // groups sorting
}, [logs, groupBy]);
const paginatedGroups = usePaginateGroups(groupData, page, rowsPerPage);
@@ -162,7 +164,7 @@ const GroupLogs: FC<Props> = ({ logs, settingsRef }) => {
ariaLabel={expandAll ? "Collapse All" : "Expand All"}
/>
</Tooltip>
<DownloadLogsButton getLogs={getLogs}/>
<DownloadLogsButton getLogs={getLogs} />
<GroupLogsConfigurators logs={logs}/>
</div>
), settingsRef.current)}

View File

@@ -1,9 +1,10 @@
import { FC, useMemo } from "preact/compat";
import React, { FC, useMemo, useState } from "preact/compat";
import { Logs } from "../../../api/types";
import "./style.scss";
import classNames from "classnames";
import GroupLogsFieldRow from "./GroupLogsFieldRow";
import { useLocalStorageBoolean } from "../../../hooks/useLocalStorageBoolean";
import useEventListener from "../../../hooks/useEventListener";
import { getFromStorage } from "../../../utils/storage";
interface Props {
log: Logs;
@@ -16,7 +17,16 @@ const GroupLogsFields: FC<Props> = ({ log, hideGroupButton }) => {
.sort(([aKey], [bKey]) => aKey.localeCompare(bKey));
}, [log]);
const [disabledHovers] = useLocalStorageBoolean("LOGS_DISABLED_HOVERS");
const [disabledHovers, setDisabledHovers] = useState(!!getFromStorage("LOGS_DISABLED_HOVERS"));
const handleUpdateStage = () => {
const newValDisabledHovers = !!getFromStorage("LOGS_DISABLED_HOVERS");
if (newValDisabledHovers !== disabledHovers) {
setDisabledHovers(newValDisabledHovers);
}
};
useEventListener("storage", handleUpdateStage);
return (
<div

View File

@@ -1,8 +1,8 @@
import React, { FC, memo, useMemo } from "preact/compat";
import React, { FC, memo, useMemo, useState } from "preact/compat";
import { Logs } from "../../../api/types";
import "./style.scss";
import useBoolean from "../../../hooks/useBoolean";
import { ArrowDownIcon, CopyIcon } from "../../../components/Main/Icons";
import { ArrowDownIcon } from "../../../components/Main/Icons";
import classNames from "classnames";
import { useLogsState } from "../../../state/logsPanel/LogsStateContext";
import dayjs from "dayjs";
@@ -10,13 +10,10 @@ import { useTimeState } from "../../../state/time/TimeStateContext";
import { marked } from "marked";
import { useSearchParams } from "react-router-dom";
import { LOGS_DATE_FORMAT, LOGS_URL_PARAMS } from "../../../constants/logs";
import useEventListener from "../../../hooks/useEventListener";
import { getFromStorage } from "../../../utils/storage";
import { parseAnsiToHtml } from "../../../utils/ansiParser";
import GroupLogsFields from "./GroupLogsFields";
import { useLocalStorageBoolean } from "../../../hooks/useLocalStorageBoolean";
import Button from "../../../components/Main/Button/Button";
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
import { useCallback, useEffect, useState } from "react";
import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
interface Props {
log: Logs;
@@ -30,8 +27,6 @@ const GroupLogsItem: FC<Props> = ({ log, displayFields = ["_msg"], onItemClick,
value: isOpenFields,
toggle: toggleOpenFields,
} = useBoolean(false);
const [copied, setCopied] = useState<boolean>(false);
const copyToClipboard = useCopyToClipboard();
const [searchParams] = useSearchParams();
const { markdownParsing, ansiParsing } = useLogsState();
@@ -73,29 +68,21 @@ const GroupLogsItem: FC<Props> = ({ log, displayFields = ["_msg"], onItemClick,
return values;
}, [log, hasFields, displayFields, ansiParsing]);
const [disabledHovers] = useLocalStorageBoolean("LOGS_DISABLED_HOVERS");
const [disabledHovers, setDisabledHovers] = useState(!!getFromStorage("LOGS_DISABLED_HOVERS"));
const handleUpdateStage = () => {
const newValDisabledHovers = !!getFromStorage("LOGS_DISABLED_HOVERS");
if (newValDisabledHovers !== disabledHovers) {
setDisabledHovers(newValDisabledHovers);
}
};
const handleClick = () => {
toggleOpenFields();
onItemClick?.(log);
};
const handleCopy = useCallback(async (e: Event) => {
e.stopPropagation();
if (copied) return;
try {
await copyToClipboard(JSON.stringify(log, null, 2));
setCopied(true);
} catch (e) {
console.error(e);
}
}, [copied, copyToClipboard]);
useEffect(() => {
if (copied === null) return;
const timeout = setTimeout(() => setCopied(false), 2000);
return () => clearTimeout(timeout);
}, [copied]);
useEventListener("storage", handleUpdateStage);
return (
<div className="vm-group-logs-row">
@@ -106,17 +93,6 @@ const GroupLogsItem: FC<Props> = ({ log, displayFields = ["_msg"], onItemClick,
})}
onClick={handleClick}
>
<Tooltip title={copied ? "Copied" : "Copy to clipboard"}>
<Button
className="vm-group-logs-row-content__copy-row"
variant="text"
color="gray"
size="small"
startIcon={<CopyIcon/>}
onClick={handleCopy}
ariaLabel="copy to clipboard"
/>
</Tooltip>
{hasFields && (
<div
className={classNames({

View File

@@ -132,7 +132,7 @@ $font-size-logs: var(--font-size-logs, $font-size-small);
&-content {
display: flex;
padding: 2px 24px 2px 0;
padding: 2px 0;
cursor: pointer;
&_interactive {
@@ -140,23 +140,8 @@ $font-size-logs: var(--font-size-logs, $font-size-small);
will-change: background-color;
}
&__copy-row {
position: absolute;
top: 0;
right: 0;
z-index: 1;
visibility: hidden;
&.vm-button {
padding: 2px;
}
}
&_interactive:hover {
background-color: $color-hover-black;
.vm-group-logs-row-content__copy-row {
visibility: visible;
}
}
&__arrow {

View File

@@ -74,7 +74,7 @@ export const useFetchLogHits = (server: string, query: string) => {
}
}
setIsLoading(prev => ({ ...prev, [id]: false }));
}, [url, query, tenant]);
}, [url, query]);
useEffect(() => {
return () => {

View File

@@ -154,13 +154,13 @@ const Relabel: FC = () => {
<div className="vm-relabeling-steps-item__row">
<span>Input Labels:</span>
<code>
<pre dangerouslySetInnerHTML={{ __html: step.errors?.inLabels || step.inLabels }}/>
<pre dangerouslySetInnerHTML={{ __html: step.inLabels }}/>
</code>
</div>
<div className="vm-relabeling-steps-item__row">
<span>Output labels:</span>
<code>
<pre dangerouslySetInnerHTML={{ __html: step.errors?.outLabels || step.outLabels }}/>
<pre dangerouslySetInnerHTML={{ __html: step.outLabels }}/>
</code>
</div>
</div>

View File

@@ -138,10 +138,6 @@ export interface RelabelStep {
rule: string;
inLabels: string;
outLabels: string;
errors: {
inLabels: string;
outLabels: string;
}
}
export interface RelabelData {

View File

@@ -1,36 +0,0 @@
import { describe, expect, it } from "vitest";
import { isDecreasing } from "./array";
describe("isDecreasing", () => {
it("should return true for an array with strictly decreasing numbers", () => {
expect(isDecreasing([5, 4, 3, 2, 1])).toBe(true);
});
it("should return false for an array with increasing numbers", () => {
expect(isDecreasing([1, 2, 3, 4, 5])).toBe(false);
});
it("should return false for an array with equal consecutive numbers", () => {
expect(isDecreasing([5, 5, 4, 3, 2])).toBe(false);
});
it("should return false for an empty array", () => {
expect(isDecreasing([])).toBe(false);
});
it("should return false for an array with a single element", () => {
expect(isDecreasing([1])).toBe(false);
});
it("should return false for an array with both increasing and decreasing numbers", () => {
expect(isDecreasing([5, 3, 4, 2, 1])).toBe(false);
});
it("should return true for an array with negative strictly decreasing numbers", () => {
expect(isDecreasing([-1, -2, -3, -4])).toBe(true);
});
it("should return false for an array with a mix of positive and negative numbers that do not strictly decrease", () => {
expect(isDecreasing([3, 2, -1, -1])).toBe(false);
});
});

View File

@@ -1,4 +1,4 @@
export const arrayEquals = (a: (string | number)[], b: (string | number)[]) => {
export const arrayEquals = (a: (string|number)[], b: (string|number)[]) => {
return a.length === b.length && a.every((val, index) => val === b[index]);
};
@@ -17,8 +17,3 @@ export function groupByMultipleKeys<T>(items: T[], keys: (keyof T)[]): { keys: s
}));
}
export const isDecreasing = (arr: number[]): boolean => {
if (arr.length < 2) return false;
return arr.every((v, i) => i === 0 || v < arr[i - 1]);
};

View File

@@ -14,13 +14,11 @@ export type StorageKeys = "AUTOCOMPLETE"
| "THEME"
| "LOGS_LIMIT"
| "LOGS_MARKDOWN"
| "LOGS_ANSI"
| "LOGS_DISABLED_HOVERS"
| "EXPLORE_METRICS_TIPS"
| "LOGS_QUERY_HISTORY"
| "METRICS_QUERY_HISTORY"
| "SERVER_URL"
| "RAW_JSON_LIVE_VIEW"
| DeprecatedStorageKeys;

View File

@@ -22,8 +22,6 @@ var (
vminsertAddrRE = regexp.MustCompile(`accepting vminsert conns at (.*:\d{1,5})$`)
vminsertClusterNativeAddrRE = regexp.MustCompile(`started TCP clusternative server at "(.*:\d{1,5})"`)
vmselectAddrRE = regexp.MustCompile(`accepting vmselect conns at (.*:\d{1,5})$`)
logsStorageDataPathRE = regexp.MustCompile(`opening storage at -storageDataPath=(.*)`)
)
// app represents an instance of some VictoriaMetrics server (such as vmstorage,

View File

@@ -1,11 +1,8 @@
package apptest
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/url"
"slices"
@@ -18,22 +15,20 @@ import (
pb "github.com/VictoriaMetrics/VictoriaMetrics/lib/prompbmarshal"
)
// APIQuerier contains methods available to Prometheus-like HTTP API for Querying
type APIQuerier interface {
APIV1Export(t *testing.T, query string, opts QueryOpts) *APIV1QueryResponse
APIV1Query(t *testing.T, query string, opts QueryOpts) *APIV1QueryResponse
APIV1QueryRange(t *testing.T, query string, opts QueryOpts) *APIV1QueryResponse
APIV1Series(t *testing.T, matchQuery string, opts QueryOpts) *APIV1SeriesResponse
APIV1ExportNative(t *testing.T, query string, opts QueryOpts) []byte
// PrometheusQuerier contains methods available to Prometheus-like HTTP API for Querying
type PrometheusQuerier interface {
PrometheusAPIV1Export(t *testing.T, query string, opts QueryOpts) *PrometheusAPIV1QueryResponse
PrometheusAPIV1Query(t *testing.T, query string, opts QueryOpts) *PrometheusAPIV1QueryResponse
PrometheusAPIV1QueryRange(t *testing.T, query string, opts QueryOpts) *PrometheusAPIV1QueryResponse
PrometheusAPIV1Series(t *testing.T, matchQuery string, opts QueryOpts) *PrometheusAPIV1SeriesResponse
}
// APIWriter contains methods for writing new data
type APIWriter interface {
// Prometheus-like APIs
APIV1Write(t *testing.T, records []pb.TimeSeries, opts QueryOpts)
APIV1ImportPrometheus(t *testing.T, records []string, opts QueryOpts)
APIV1ImportCSV(t *testing.T, records []string, opts QueryOpts)
APIV1ImportNative(t *testing.T, data []byte, opts QueryOpts)
// Writer contains methods for writing new data
type Writer interface {
// Prometheus APIs
PrometheusAPIV1Write(t *testing.T, records []pb.TimeSeries, opts QueryOpts)
PrometheusAPIV1ImportPrometheus(t *testing.T, records []string, opts QueryOpts)
PrometheusAPIV1ImportCSV(t *testing.T, records []string, opts QueryOpts)
// Graphit APIs
GraphiteWrite(t *testing.T, records []string, opts QueryOpts)
@@ -54,11 +49,11 @@ type StorageMerger interface {
ForceMerge(t *testing.T)
}
// WriteQuerier encompasses the methods for writing, flushing and
// PrometheusWriteQuerier encompasses the methods for writing, flushing and
// querying the data.
type WriteQuerier interface {
APIWriter
APIQuerier
type PrometheusWriteQuerier interface {
Writer
PrometheusQuerier
StorageFlusher
StorageMerger
}
@@ -114,33 +109,9 @@ func (qos *QueryOpts) getTenant() string {
return qos.Tenant
}
// QueryOptsLogs contains various params used for VictoriaLogs querying or ingesting data
type QueryOptsLogs struct {
MessageField string
StreamFields string
TimeField string
}
func (qos *QueryOptsLogs) asURLValues() url.Values {
uv := make(url.Values)
addNonEmpty := func(name string, values ...string) {
for _, value := range values {
if len(value) == 0 {
continue
}
uv.Add(name, value)
}
}
addNonEmpty("_time_field", qos.TimeField)
addNonEmpty("_stream_fields", qos.StreamFields)
addNonEmpty("_msg_field", qos.MessageField)
return uv
}
// APIV1QueryResponse is an inmemory representation of the
// PrometheusAPIV1QueryResponse is an inmemory representation of the
// /prometheus/api/v1/query or /prometheus/api/v1/query_range response.
type APIV1QueryResponse struct {
type PrometheusAPIV1QueryResponse struct {
Status string
Data *QueryData
ErrorType string
@@ -148,12 +119,12 @@ type APIV1QueryResponse struct {
IsPartial bool
}
// NewAPIV1QueryResponse is a test helper function that creates a new
// instance of APIV1QueryResponse by unmarshalling a json string.
func NewAPIV1QueryResponse(t *testing.T, s string) *APIV1QueryResponse {
// NewPrometheusAPIV1QueryResponse is a test helper function that creates a new
// instance of PrometheusAPIV1QueryResponse by unmarshalling a json string.
func NewPrometheusAPIV1QueryResponse(t *testing.T, s string) *PrometheusAPIV1QueryResponse {
t.Helper()
res := &APIV1QueryResponse{}
res := &PrometheusAPIV1QueryResponse{}
if err := json.Unmarshal([]byte(s), res); err != nil {
t.Fatalf("could not unmarshal query response data=\n%s\n: %v", string(s), err)
}
@@ -161,7 +132,7 @@ func NewAPIV1QueryResponse(t *testing.T, s string) *APIV1QueryResponse {
}
// Sort performs data.Result sort by metric labels
func (pqr *APIV1QueryResponse) Sort() {
func (pqr *PrometheusAPIV1QueryResponse) Sort() {
if pqr.Data == nil {
return
}
@@ -257,9 +228,9 @@ func (s *Sample) UnmarshalJSON(b []byte) error {
return nil
}
// APIV1SeriesResponse is an inmemory representation of the
// PrometheusAPIV1SeriesResponse is an inmemory representation of the
// /prometheus/api/v1/series response.
type APIV1SeriesResponse struct {
type PrometheusAPIV1SeriesResponse struct {
Status string
IsPartial bool
Data []map[string]string
@@ -268,12 +239,12 @@ type APIV1SeriesResponse struct {
Error string
}
// NewAPIV1SeriesResponse is a test helper function that creates a new
// instance of APIV1SeriesResponse by unmarshalling a json string.
func NewAPIV1SeriesResponse(t *testing.T, s string) *APIV1SeriesResponse {
// NewPrometheusAPIV1SeriesResponse is a test helper function that creates a new
// instance of PrometheusAPIV1SeriesResponse by unmarshalling a json string.
func NewPrometheusAPIV1SeriesResponse(t *testing.T, s string) *PrometheusAPIV1SeriesResponse {
t.Helper()
res := &APIV1SeriesResponse{}
res := &PrometheusAPIV1SeriesResponse{}
if err := json.Unmarshal([]byte(s), res); err != nil {
t.Fatalf("could not unmarshal series response data:\n%s\n err: %v", string(s), err)
}
@@ -281,7 +252,7 @@ func NewAPIV1SeriesResponse(t *testing.T, s string) *APIV1SeriesResponse {
}
// Sort sorts the response data.
func (r *APIV1SeriesResponse) Sort() *APIV1SeriesResponse {
func (r *PrometheusAPIV1SeriesResponse) Sort() *PrometheusAPIV1SeriesResponse {
str := func(m map[string]string) string {
s := []string{}
for k, v := range m {
@@ -395,13 +366,6 @@ type TSDBStatusResponse struct {
Data TSDBStatusResponseData
}
// AdminTenantsResponse is an in-memory representation of the json response
// returned by the /api/v1/admin/tenants endpoint.
type AdminTenantsResponse struct {
Status string
Data []string
}
// Sort performs sorting of stats entries
func (tsr *TSDBStatusResponse) Sort() {
sortTSDBStatusResponseEntries(tsr.Data.SeriesCountByLabelName)
@@ -444,44 +408,3 @@ func sortTSDBStatusResponseEntries(entries []TSDBStatusResponseEntry) {
return left.Count < right.Count
})
}
// LogsQLQueryResponse is an in-memory representation of the
// /select/logsql/query response.
type LogsQLQueryResponse struct {
LogLines []string
}
// NewLogsQLQueryResponse is a test helper function that creates a new
// instance of LogsQLQueryResponse by unmarshalling a json string.
func NewLogsQLQueryResponse(t *testing.T, s string) *LogsQLQueryResponse {
t.Helper()
res := &LogsQLQueryResponse{}
if len(s) == 0 {
return res
}
bs := bytes.NewBufferString(s)
for {
logLine, err := bs.ReadString('\n')
if err != nil {
if errors.Is(err, io.EOF) {
if len(logLine) > 0 {
t.Fatalf("BUG: unexpected non-empty line=%q with io.EOF", logLine)
}
break
}
t.Fatalf("BUG: cannot read logline from buffer: %s", err)
}
var lv map[string]any
if err := json.Unmarshal([]byte(logLine), &lv); err != nil {
t.Fatalf("cannot parse log line=%q: %s", logLine, err)
}
delete(lv, "_stream_id")
normalizedLine, err := json.Marshal(lv)
if err != nil {
t.Fatalf("cannot marshal parsed logline=%q: %s", logLine, err)
}
res.LogLines = append(res.LogLines, string(normalizedLine))
}
return res
}

View File

@@ -28,7 +28,6 @@ type Stopper interface {
// NewTestCase creates a new test case.
func NewTestCase(t *testing.T) *TestCase {
t.Parallel()
return &TestCase{t, NewClient(), make(map[string]Stopper)}
}
@@ -146,7 +145,7 @@ func (tc *TestCase) MustStartVmagent(instance string, flags []string, promScrape
// Vmcluster represents a typical cluster setup: several vmstorage replicas, one
// vminsert, and one vmselect.
//
// Both Vmsingle and Vmcluster implement the WriteQuerier used in
// Both Vmsingle and Vmcluster implement the PrometheusWriteQuerier used in
// business logic tests to abstract out the infrasture.
//
// This type is not suitable for infrastructure tests where custom cluster
@@ -189,39 +188,9 @@ func (tc *TestCase) MustStartVmauth(instance string, flags []string, configFileY
return app
}
// MustStartVmbackup is a test helper that starts an instance of vmbackup
// and waits until the app exits. It fails the test if the app fails to start or
// exits with non zero code.
func (tc *TestCase) MustStartVmbackup(instance, storageDataPath, snapshotCreateURL, dst string) {
tc.t.Helper()
if err := StartVmbackup(instance, storageDataPath, snapshotCreateURL, dst); err != nil {
tc.t.Fatalf("vmbackup %q failed to start or exited with non-zero code: %v", instance, err)
}
// Do not add the process to the list of running apps using
// tc.addApp(instance, app), because the method blocks until the process
// exits.
}
// MustStartVmrestore is a test helper that starts an instance of vmrestore
// and waits until the app exits. It fails the test if the app fails to start or
// exits with non zero code.
func (tc *TestCase) MustStartVmrestore(instance, src, storageDataPath string) {
tc.t.Helper()
if err := StartVmrestore(instance, src, storageDataPath); err != nil {
tc.t.Fatalf("vmrestore %q failed to start or exited with non-zero code: %v", instance, err)
}
// Do not add the process to the list of running apps using
// tc.addApp(instance, app), because the method blocks until the process
// exits.
}
// MustStartDefaultCluster starts a typical cluster configuration with default
// flags.
func (tc *TestCase) MustStartDefaultCluster() *Vmcluster {
func (tc *TestCase) MustStartDefaultCluster() PrometheusWriteQuerier {
tc.t.Helper()
return tc.MustStartCluster(&ClusterOptions{
@@ -255,7 +224,7 @@ type ClusterOptions struct {
}
// MustStartCluster starts a typical cluster configuration with custom flags.
func (tc *TestCase) MustStartCluster(opts *ClusterOptions) *Vmcluster {
func (tc *TestCase) MustStartCluster(opts *ClusterOptions) PrometheusWriteQuerier {
tc.t.Helper()
opts.Vmstorage1Flags = append(opts.Vmstorage1Flags, []string{
@@ -284,13 +253,15 @@ func (tc *TestCase) MustStartCluster(opts *ClusterOptions) *Vmcluster {
}
// MustStartVmctl is a test helper function that starts an instance of vmctl
func (tc *TestCase) MustStartVmctl(instance string, flags []string) {
func (tc *TestCase) MustStartVmctl(instance string, flags []string) *Vmctl {
tc.t.Helper()
err := StartVmctl(instance, flags)
app, err := StartVmctl(instance, flags)
if err != nil {
tc.t.Fatalf("Could not start %s: %v", instance, err)
}
tc.addApp(instance, app)
return app
}
func (tc *TestCase) addApp(instance string, app Stopper) {
@@ -309,8 +280,8 @@ func (tc *TestCase) StopApp(instance string) {
}
}
// StopWriteQuerier stop all apps that are a part of the pwq.
func (tc *TestCase) StopWriteQuerier(pwq WriteQuerier) {
// StopPrometheusWriteQuerier stop all apps that are a part of the pwq.
func (tc *TestCase) StopPrometheusWriteQuerier(pwq PrometheusWriteQuerier) {
tc.t.Helper()
switch t := pwq.(type) {
case *Vmsingle:
@@ -409,27 +380,3 @@ func (tc *TestCase) Assert(opts *AssertOptions) {
tc.t.Error(msg)
}
}
// MustStartDefaultVlsingle is a test helper function that starts an instance of
// vlsingle with defaults suitable for most tests.
func (tc *TestCase) MustStartDefaultVlsingle() *Vlsingle {
tc.t.Helper()
return tc.MustStartVlsingle("vlsingle", []string{
"-storageDataPath=" + tc.Dir() + "/vlsingle",
"-retentionPeriod=100y",
})
}
// MustStartVlsingle is a test helper function that starts an instance of
// vlsingle and fails the test if the app fails to start.
func (tc *TestCase) MustStartVlsingle(instance string, flags []string) *Vlsingle {
tc.t.Helper()
app, err := StartVlsingle(instance, flags, tc.cli)
if err != nil {
tc.t.Fatalf("Could not start %s: %v", instance, err)
}
tc.addApp(instance, app)
return app
}

View File

@@ -1,239 +0,0 @@
package tests
import (
"fmt"
"path/filepath"
"slices"
"testing"
"time"
at "github.com/VictoriaMetrics/VictoriaMetrics/apptest"
)
type testBackupRestoreOpts struct {
startSUT func() at.WriteQuerier
stopSUT func()
storageDataPaths []string
snapshotCreateURLs func(at.WriteQuerier) []string
}
func TestSingleBackupRestore(t *testing.T) {
tc := at.NewTestCase(t)
defer tc.Stop()
storageDataPath := filepath.Join(tc.Dir(), "vmsingle")
opts := testBackupRestoreOpts{
startSUT: func() at.WriteQuerier {
return tc.MustStartVmsingle("vmsingle", []string{
"-storageDataPath=" + storageDataPath,
"-retentionPeriod=100y",
"-search.maxStalenessInterval=1m",
})
},
stopSUT: func() {
tc.StopApp("vmsingle")
},
storageDataPaths: []string{
storageDataPath,
},
snapshotCreateURLs: func(sut at.WriteQuerier) []string {
return []string{
sut.(*at.Vmsingle).SnapshotCreateURL(),
}
},
}
testBackupRestore(tc, opts)
}
func TestClusterBackupRestore(t *testing.T) {
tc := at.NewTestCase(t)
defer tc.Stop()
storage1DataPath := filepath.Join(tc.Dir(), "vmstorage1")
storage2DataPath := filepath.Join(tc.Dir(), "vmstorage2")
opts := testBackupRestoreOpts{
startSUT: func() at.WriteQuerier {
return tc.MustStartCluster(&at.ClusterOptions{
Vmstorage1Instance: "vmstorage1",
Vmstorage1Flags: []string{
"-storageDataPath=" + storage1DataPath,
"-retentionPeriod=100y",
},
Vmstorage2Instance: "vmstorage2",
Vmstorage2Flags: []string{
"-storageDataPath=" + storage2DataPath,
"-retentionPeriod=100y",
},
VminsertInstance: "vminsert",
VminsertFlags: []string{},
VmselectInstance: "vmselect",
VmselectFlags: []string{
"-search.maxStalenessInterval=1m",
},
})
},
stopSUT: func() {
tc.StopApp("vminsert")
tc.StopApp("vmselect")
tc.StopApp("vmstorage1")
tc.StopApp("vmstorage2")
},
storageDataPaths: []string{
storage1DataPath,
storage2DataPath,
},
snapshotCreateURLs: func(sut at.WriteQuerier) []string {
c := sut.(*at.Vmcluster)
return []string{
c.Vmstorages[0].SnapshotCreateURL(),
c.Vmstorages[1].SnapshotCreateURL(),
}
},
}
testBackupRestore(tc, opts)
}
func testBackupRestore(tc *at.TestCase, opts testBackupRestoreOpts) {
t := tc.T()
const msecPerMinute = 60 * 1000
genData := func(count int, prefix string, start int64) (recs []string, wantSeries []map[string]string, wantQueryResults []*at.QueryResult) {
recs = make([]string, count)
wantSeries = make([]map[string]string, count)
wantQueryResults = make([]*at.QueryResult, count)
for i := range count {
name := fmt.Sprintf("%s_%03d", prefix, i)
value := float64(i)
timestamp := start + int64(i)*msecPerMinute
recs[i] = fmt.Sprintf("%s %f %d", name, value, timestamp)
wantSeries[i] = map[string]string{"__name__": name}
wantQueryResults[i] = &at.QueryResult{
Metric: map[string]string{"__name__": name},
Samples: []*at.Sample{{Timestamp: timestamp, Value: value}},
}
}
return recs, wantSeries, wantQueryResults
}
backupBaseDir, err := filepath.Abs(filepath.Join(tc.Dir(), "backups"))
if err != nil {
t.Fatalf("could not get absolute path for the backup base dir")
}
// assertSeries retrieves set of all metric names from the storage and
// compares it with the expected set.
assertSeries := func(app at.APIQuerier, query string, start, end int64, want []map[string]string) {
t.Helper()
tc.Assert(&at.AssertOptions{
Msg: "unexpected /api/v1/series response",
Got: func() any {
return app.APIV1Series(t, query, at.QueryOpts{
Start: fmt.Sprintf("%d", start),
End: fmt.Sprintf("%d", end),
}).Sort()
},
Want: &at.APIV1SeriesResponse{
Status: "success",
Data: want,
},
FailNow: true,
})
}
// assertSeries retrieves all data from the storage and compares it with the
// expected result.
assertQueryResults := func(app at.APIQuerier, query string, start, end int64, want []*at.QueryResult) {
t.Helper()
tc.Assert(&at.AssertOptions{
Msg: "unexpected /api/v1/query_range response",
Got: func() any {
return app.APIV1QueryRange(t, query, at.QueryOpts{
Start: fmt.Sprintf("%d", start),
End: fmt.Sprintf("%d", end),
Step: "60s",
})
},
Want: &at.APIV1QueryResponse{
Status: "success",
Data: &at.QueryData{
ResultType: "matrix",
Result: want,
},
},
FailNow: true,
Retries: 300,
})
}
createBackup := func(sut at.WriteQuerier, name string) {
for i, storageDataPath := range opts.storageDataPaths {
replica := fmt.Sprintf("replica-%d", i)
instance := fmt.Sprintf("vmbackup-%s-%s", name, replica)
snapshotCreateURL := opts.snapshotCreateURLs(sut)[i]
backupPath := "fs://" + filepath.Join(backupBaseDir, name, replica)
tc.MustStartVmbackup(instance, storageDataPath, snapshotCreateURL, backupPath)
}
}
restoreFromBackup := func(name string) {
for i, storageDataPath := range opts.storageDataPaths {
replica := fmt.Sprintf("replica-%d", i)
instance := fmt.Sprintf("vmrestore-%s-%s", name, replica)
backupPath := "fs://" + filepath.Join(backupBaseDir, name, replica)
tc.MustStartVmrestore(instance, backupPath, storageDataPath)
}
}
// Use the same number of metrics and time range for all the data ingestions
// below.
const numMetrics = 1000
// With 1000 metrics (one per minute), the time range spans 2 months.
end := time.Date(2025, 3, 1, 10, 0, 0, 0, time.UTC).UnixMilli()
start := end - numMetrics*msecPerMinute
// Verify backup/restore:
//
// - Start vmsingle with empty storage data dir.
// - Ingest first batch or records (batch1) and ensure they can be queried.
// - Create batch1 backup
// - Ingest second batch of records (batch2) and ensure the queries return
// (batch1 + batch2) data.
// - Stop vmsingle
// - Restore batch1 from backup
// - Start vmsingle
// - Ensure that the queries return batch1 data only.
batch1Data, wantBatch1Series, wantBatch1QueryResults := genData(numMetrics, "batch1", start)
batch2Data, wantBatch2Series, wantBatch2QueryResults := genData(numMetrics, "batch2", start)
wantBatch12Series := slices.Concat(wantBatch1Series, wantBatch2Series)
wantBatch12QueryResults := slices.Concat(wantBatch1QueryResults, wantBatch2QueryResults)
sut := opts.startSUT()
sut.APIV1ImportPrometheus(t, batch1Data, at.QueryOpts{})
sut.ForceFlush(t)
assertSeries(sut, `{__name__=~"batch1.*"}`, start, end, wantBatch1Series)
assertQueryResults(sut, `{__name__=~"batch1.*"}`, start, end, wantBatch1QueryResults)
createBackup(sut, "batch1")
sut.APIV1ImportPrometheus(t, batch2Data, at.QueryOpts{})
sut.ForceFlush(t)
assertSeries(sut, `{__name__=~"batch(1|2).*"}`, start, end, wantBatch12Series)
assertQueryResults(sut, `{__name__=~"batch(1|2).*"}`, start, end, wantBatch12QueryResults)
createBackup(sut, "batch12")
opts.stopSUT()
restoreFromBackup("batch1")
sut = opts.startSUT()
assertSeries(sut, `{__name__=~"batch1.*"}`, start, end, wantBatch1Series)
assertQueryResults(sut, `{__name__=~"batch1.*"}`, start, end, wantBatch1QueryResults)
}

View File

@@ -5,13 +5,12 @@ import (
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/VictoriaMetrics/VictoriaMetrics/apptest"
at "github.com/VictoriaMetrics/VictoriaMetrics/apptest"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/decimal"
pb "github.com/VictoriaMetrics/VictoriaMetrics/lib/prompbmarshal"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
)
func TestSingleDeduplication_dedulicationIsOff(t *testing.T) {
@@ -81,7 +80,7 @@ func TestClusterDeduplication_deduplicationIsOn(t *testing.T) {
}
// See https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#deduplication
func testDeduplication(tc *at.TestCase, sut at.WriteQuerier, deduplicationIsOn bool) {
func testDeduplication(tc *at.TestCase, sut at.PrometheusWriteQuerier, deduplicationIsOn bool) {
t := tc.T()
firstDayOfThisMonth := func() time.Time {
@@ -135,11 +134,11 @@ func testDeduplication(tc *at.TestCase, sut at.WriteQuerier, deduplicationIsOn b
},
}
sut.APIV1Write(t, data, apptest.QueryOpts{})
sut.PrometheusAPIV1Write(t, data, apptest.QueryOpts{})
sut.ForceFlush(t)
sut.ForceMerge(t)
wantDuplicates := &at.APIV1QueryResponse{
wantDuplicates := &at.PrometheusAPIV1QueryResponse{
Status: "success",
Data: &at.QueryData{
ResultType: "matrix",
@@ -167,7 +166,7 @@ func testDeduplication(tc *at.TestCase, sut at.WriteQuerier, deduplicationIsOn b
},
},
}
wantDeduped := &at.APIV1QueryResponse{
wantDeduped := &at.PrometheusAPIV1QueryResponse{
Status: "success",
Data: &at.QueryData{
ResultType: "matrix",
@@ -208,7 +207,7 @@ func testDeduplication(tc *at.TestCase, sut at.WriteQuerier, deduplicationIsOn b
tc.Assert(&at.AssertOptions{
Msg: "unexpected response",
Got: func() any {
got := sut.APIV1Export(t, `{__name__=~"metric.*"}`, apptest.QueryOpts{
got := sut.PrometheusAPIV1Export(t, `{__name__=~"metric.*"}`, apptest.QueryOpts{
ReduceMemUsage: "1",
Start: fmt.Sprintf("%d", start.UnixMilli()),
End: fmt.Sprintf("%d", end.UnixMilli()),

View File

@@ -1,73 +0,0 @@
package tests
import (
"os"
"testing"
at "github.com/VictoriaMetrics/VictoriaMetrics/apptest"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
)
func TestSingleExportImportNative(t *testing.T) {
os.RemoveAll(t.Name())
tc := at.NewTestCase(t)
defer tc.Stop()
sut := tc.MustStartDefaultVmsingle()
testExportImportNative(tc.T(), sut)
}
func TestClusterExportImportNative(t *testing.T) {
os.RemoveAll(t.Name())
tc := at.NewTestCase(t)
defer tc.Stop()
sut := tc.MustStartDefaultCluster()
testExportImportNative(tc.T(), sut)
}
// testExportImportNative test export and import in VictoriaMetrics native format.
// see: https://docs.victoriametrics.com/#how-to-import-data-in-native-format
func testExportImportNative(t *testing.T, sut at.WriteQuerier) {
// create test data
sut.APIV1ImportPrometheus(t, []string{
`native_export_import 10 1707123456700`, // 2024-02-05T08:57:36.700Z
}, at.QueryOpts{
ExtraLabels: []string{"el1=elv1", "el2=elv2"},
})
sut.ForceFlush(t)
// export test data via native export API
exportResult := sut.APIV1ExportNative(t, "native_export_import", at.QueryOpts{
Start: "2024-02-05T08:50:00.700Z",
End: "2024-02-05T09:00:00.700Z",
})
// re-import test data via native import API
sut.APIV1ImportNative(t, exportResult, at.QueryOpts{})
sut.ForceFlush(t)
// check query result
got := sut.APIV1QueryRange(t, "native_export_import", at.QueryOpts{
Start: "2024-02-05T08:57:36.700Z",
End: "2024-02-05T08:57:36.700Z",
Step: "60s",
})
cmpOptions := []cmp.Option{
cmpopts.IgnoreFields(at.APIV1QueryResponse{}, "Status", "Data.ResultType"),
cmpopts.EquateNaNs(),
}
want := at.NewAPIV1QueryResponse(t, `{"data": {"result": [{"metric": {"__name__": "native_export_import", "el1": "elv1", "el2":"elv2"}, "values": []}]}}`)
want.Data.Result[0].Samples = []*at.Sample{
at.NewSample(t, "2024-02-05T08:57:36.700Z", 10),
}
if diff := cmp.Diff(want, got, cmpOptions...); diff != "" {
t.Errorf("unexpected response (-want, +got):\n%s", diff)
}
}

View File

@@ -8,7 +8,6 @@ import (
"github.com/google/go-cmp/cmp/cmpopts"
at "github.com/VictoriaMetrics/VictoriaMetrics/apptest"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
pb "github.com/VictoriaMetrics/VictoriaMetrics/lib/prompbmarshal"
)
@@ -22,7 +21,7 @@ func TestSingleIngestionProtocols(t *testing.T) {
wantMetrics []map[string]string
wantSamples []*at.Sample
}
f := func(sut at.APIQuerier, opts *opts) {
f := func(sut at.PrometheusQuerier, opts *opts) {
t.Helper()
wantResult := []*at.QueryResult{}
for idx, wm := range opts.wantMetrics {
@@ -35,16 +34,16 @@ func TestSingleIngestionProtocols(t *testing.T) {
tc.Assert(&at.AssertOptions{
Msg: "unexpected /export query response",
Got: func() any {
got := sut.APIV1Export(t, opts.query, at.QueryOpts{
got := sut.PrometheusAPIV1Export(t, opts.query, at.QueryOpts{
Start: "2024-02-05T08:50:00.700Z",
End: "2024-02-05T09:00:00.700Z",
})
got.Sort()
return got
},
Want: &at.APIV1QueryResponse{Data: &at.QueryData{Result: wantResult}},
Want: &at.PrometheusAPIV1QueryResponse{Data: &at.QueryData{Result: wantResult}},
CmpOpts: []cmp.Option{
cmpopts.IgnoreFields(at.APIV1QueryResponse{}, "Status", "Data.ResultType"),
cmpopts.IgnoreFields(at.PrometheusAPIV1QueryResponse{}, "Status", "Data.ResultType"),
},
})
}
@@ -109,7 +108,7 @@ func TestSingleIngestionProtocols(t *testing.T) {
})
// CSV import
sut.APIV1ImportCSV(t, []string{
sut.PrometheusAPIV1ImportCSV(t, []string{
`GOOG,1.23,4.56,NYSE,1707123457`,
`MSFT,23,56,NASDAQ,1707123457`,
}, at.QueryOpts{
@@ -158,7 +157,7 @@ func TestSingleIngestionProtocols(t *testing.T) {
})
// prometheus text exposition format
sut.APIV1ImportPrometheus(t, []string{
sut.PrometheusAPIV1ImportPrometheus(t, []string{
`importprometheus_series 10 1707123456700`, // 2024-02-05T08:57:36.700Z
`importprometheus_series2{label="foo",label1="value1"} 20 1707123456800`, // 2024-02-05T08:57:36.800Z
}, at.QueryOpts{
@@ -227,7 +226,7 @@ func TestSingleIngestionProtocols(t *testing.T) {
},
},
}
sut.APIV1Write(t, pbData, at.QueryOpts{})
sut.PrometheusAPIV1Write(t, pbData, at.QueryOpts{})
sut.ForceFlush(t)
f(sut, &opts{
query: `{__name__=~"prometheusrw.+"}`,
@@ -282,22 +281,22 @@ func TestClusterIngestionProtocols(t *testing.T) {
tc.Assert(&at.AssertOptions{
Msg: "unexpected /export query response",
Got: func() any {
got := vmselect.APIV1Export(t, opts.query, at.QueryOpts{
got := vmselect.PrometheusAPIV1Export(t, opts.query, at.QueryOpts{
Start: "2024-02-05T08:50:00.700Z",
End: "2024-02-05T09:00:00.700Z",
})
got.Sort()
return got
},
Want: &at.APIV1QueryResponse{Data: &at.QueryData{Result: wantResult}},
Want: &at.PrometheusAPIV1QueryResponse{Data: &at.QueryData{Result: wantResult}},
CmpOpts: []cmp.Option{
cmpopts.IgnoreFields(at.APIV1QueryResponse{}, "Status", "Data.ResultType"),
cmpopts.IgnoreFields(at.PrometheusAPIV1QueryResponse{}, "Status", "Data.ResultType"),
},
})
}
// prometheus text exposition format
vminsert.APIV1ImportPrometheus(t, []string{
vminsert.PrometheusAPIV1ImportPrometheus(t, []string{
`importprometheus_series 10 1707123456700`, // 2024-02-05T08:57:36.700Z
`importprometheus_series2{label="foo",label1="value1"} 20 1707123456800`, // 2024-02-05T08:57:36.800Z
}, at.QueryOpts{
@@ -358,7 +357,7 @@ func TestClusterIngestionProtocols(t *testing.T) {
})
// CSV import
vminsert.APIV1ImportCSV(t, []string{
vminsert.PrometheusAPIV1ImportCSV(t, []string{
`GOOG,1.23,4.56,NYSE,1707123457`, // 2024-02-05T08:57:37.000Z
`MSFT,23,56,NASDAQ,1707123457`, // 2024-02-05T08:57:37.000Z
}, at.QueryOpts{
@@ -474,7 +473,7 @@ func TestClusterIngestionProtocols(t *testing.T) {
},
},
}
vminsert.APIV1Write(t, pbData, at.QueryOpts{})
vminsert.PrometheusAPIV1Write(t, pbData, at.QueryOpts{})
vmstorage.ForceFlush(t)
f(&opts{
query: `{__name__=~"prometheusrw.+"}`,
@@ -494,65 +493,3 @@ func TestClusterIngestionProtocols(t *testing.T) {
},
})
}
func TestVlsingleIngestionProtocols(t *testing.T) {
os.RemoveAll(t.Name())
tc := at.NewTestCase(t)
defer tc.Stop()
sut := tc.MustStartDefaultVlsingle()
type opts struct {
query string
wantLogLines []string
}
f := func(opts *opts) {
t.Helper()
sut.ForceFlush(t)
got := sut.LogsQLQuery(t, opts.query, at.QueryOptsLogs{})
assertLogsQLResponseEqual(t, got, &at.LogsQLQueryResponse{LogLines: opts.wantLogLines})
}
// json line ingest
sut.JSONLineWrite(t, []string{
`{"_msg":"ingest jsonline","_time": "2025-06-05T14:30:19.088007Z", "foo":"bar"}`,
`{"_msg":"ingest jsonline","_time": "2025-06-05T14:30:19.088007Z", "bar":"foo"}`,
}, at.QueryOptsLogs{})
f(&opts{
query: "ingest jsonline",
wantLogLines: []string{
`{"_msg":"ingest jsonline","_stream":"{}","_time":"2025-06-05T14:30:19.088007Z","bar":"foo"}`,
`{"_msg":"ingest jsonline","_stream":"{}","_time":"2025-06-05T14:30:19.088007Z","foo":"bar"}`,
},
})
// native format ingest
sut.NativeWrite(t, []logstorage.InsertRow{
{
StreamTagsCanonical: canonicalStreamTagsFromSet(map[string]string{"foo": "bar"}),
Timestamp: 1749141697409000000, // 2025-06-05T:18:41:37.000000Z
Fields: []logstorage.Field{
{
Name: "_msg",
Value: "ingest native",
},
{
Name: "qwe",
Value: "rty",
},
},
},
}, at.QueryOpts{})
f(&opts{
query: "ingest native",
wantLogLines: []string{
`{"_msg":"ingest native","_time":"2025-06-05T16:41:37.409Z", "_stream":"{foo=\"bar\"}", "qwe": "rty"}`,
},
})
}
func canonicalStreamTagsFromSet(set map[string]string) string {
var st logstorage.StreamTags
for key, value := range set {
st.Add(key, value)
}
return string(st.MarshalCanonical(nil))
}

View File

@@ -1,10 +1,6 @@
package tests
import (
"encoding/json"
"os"
"sort"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
@@ -55,10 +51,10 @@ func TestClusterKeyConceptsQueryData(t *testing.T) {
}
// testKeyConceptsQueryData verifies cases from https://docs.victoriametrics.com/victoriametrics/keyconcepts/#query-data
func testKeyConceptsQueryData(t *testing.T, sut at.WriteQuerier) {
func testKeyConceptsQueryData(t *testing.T, sut at.PrometheusWriteQuerier) {
// Insert example data from documentation.
sut.APIV1ImportPrometheus(t, docData, at.QueryOpts{})
sut.PrometheusAPIV1ImportPrometheus(t, docData, at.QueryOpts{})
sut.ForceFlush(t)
testInstantQuery(t, sut)
@@ -69,14 +65,14 @@ func testKeyConceptsQueryData(t *testing.T, sut at.WriteQuerier) {
// testInstantQuery verifies the statements made in the `Instant query` section
// of the VictoriaMetrics documentation. See:
// https://docs.victoriametrics.com/victoriametrics/keyconcepts/#instant-query
func testInstantQuery(t *testing.T, q at.APIQuerier) {
func testInstantQuery(t *testing.T, q at.PrometheusQuerier) {
// Get the value of the foo_bar time series at 2022-05-10T08:03:00Z with the
// step of 5m and timeout 5s. There is no sample at exactly this timestamp.
// Therefore, VictoriaMetrics will search for the nearest sample within the
// [time-5m..time] interval.
got := q.APIV1Query(t, "foo_bar", at.QueryOpts{Time: "2022-05-10T08:03:00.000Z", Step: "5m"})
want := at.NewAPIV1QueryResponse(t, `{"data":{"result":[{"metric":{"__name__":"foo_bar"},"value":[1652169780,"3"]}]}}`)
opt := cmpopts.IgnoreFields(at.APIV1QueryResponse{}, "Status", "Data.ResultType")
got := q.PrometheusAPIV1Query(t, "foo_bar", at.QueryOpts{Time: "2022-05-10T08:03:00.000Z", Step: "5m"})
want := at.NewPrometheusAPIV1QueryResponse(t, `{"data":{"result":[{"metric":{"__name__":"foo_bar"},"value":[1652169780,"3"]}]}}`)
opt := cmpopts.IgnoreFields(at.PrometheusAPIV1QueryResponse{}, "Status", "Data.ResultType")
if diff := cmp.Diff(want, got, opt); diff != "" {
t.Errorf("unexpected response (-want, +got):\n%s", diff)
}
@@ -86,7 +82,7 @@ func testInstantQuery(t *testing.T, q at.APIQuerier) {
// Therefore, VictoriaMetrics will search for the nearest sample within the
// [time-1m..time] interval. Since the nearest sample is 2m away and the
// step is 1m, then the VictoriaMetrics must return empty response.
got = q.APIV1Query(t, "foo_bar", at.QueryOpts{Time: "2022-05-10T08:18:00.000Z", Step: "1m"})
got = q.PrometheusAPIV1Query(t, "foo_bar", at.QueryOpts{Time: "2022-05-10T08:18:00.000Z", Step: "1m"})
if len(got.Data.Result) > 0 {
t.Errorf("unexpected response: got non-empty result, want empty result:\n%v", got)
}
@@ -95,14 +91,14 @@ func testInstantQuery(t *testing.T, q at.APIQuerier) {
// testRangeQuery verifies the statements made in the `Range query` section of
// the VictoriaMetrics documentation. See:
// https://docs.victoriametrics.com/victoriametrics/keyconcepts/#range-query
func testRangeQuery(t *testing.T, q at.APIQuerier) {
func testRangeQuery(t *testing.T, q at.PrometheusQuerier) {
f := func(start, end, step string, wantSamples []*at.Sample) {
t.Helper()
got := q.APIV1QueryRange(t, "foo_bar", at.QueryOpts{Start: start, End: end, Step: step})
want := at.NewAPIV1QueryResponse(t, `{"data": {"result": [{"metric": {"__name__": "foo_bar"}, "values": []}]}}`)
got := q.PrometheusAPIV1QueryRange(t, "foo_bar", at.QueryOpts{Start: start, End: end, Step: step})
want := at.NewPrometheusAPIV1QueryResponse(t, `{"data": {"result": [{"metric": {"__name__": "foo_bar"}, "values": []}]}}`)
want.Data.Result[0].Samples = wantSamples
opt := cmpopts.IgnoreFields(at.APIV1QueryResponse{}, "Status", "Data.ResultType")
opt := cmpopts.IgnoreFields(at.PrometheusAPIV1QueryResponse{}, "Status", "Data.ResultType")
if diff := cmp.Diff(want, got, opt); diff != "" {
t.Errorf("unexpected response (-want, +got):\n%s", diff)
}
@@ -168,11 +164,11 @@ func testRangeQuery(t *testing.T, q at.APIQuerier) {
// will not produce ephemeral points.
//
// See: https://docs.victoriametrics.com/victoriametrics/keyconcepts/#range-query
func testRangeQueryIsEquivalentToManyInstantQueries(t *testing.T, q at.APIQuerier) {
func testRangeQueryIsEquivalentToManyInstantQueries(t *testing.T, q at.PrometheusQuerier) {
f := func(timestamp string, want *at.Sample) {
t.Helper()
gotInstant := q.APIV1Query(t, "foo_bar", at.QueryOpts{Time: timestamp, Step: "1m"})
gotInstant := q.PrometheusAPIV1Query(t, "foo_bar", at.QueryOpts{Time: timestamp, Step: "1m"})
if want == nil {
if got, want := len(gotInstant.Data.Result), 0; got != want {
t.Errorf("unexpected instant result size: got %d, want %d", got, want)
@@ -185,7 +181,7 @@ func testRangeQueryIsEquivalentToManyInstantQueries(t *testing.T, q at.APIQuerie
}
}
rangeRes := q.APIV1QueryRange(t, "foo_bar", at.QueryOpts{
rangeRes := q.PrometheusAPIV1QueryRange(t, "foo_bar", at.QueryOpts{
Start: "2022-05-10T07:59:00.000Z",
End: "2022-05-10T08:17:00.000Z",
Step: "1m",
@@ -231,7 +227,7 @@ func TestClusterMillisecondPrecisionInInstantQueries(t *testing.T) {
testMillisecondPrecisionInInstantQueries(tc, sut)
}
func testMillisecondPrecisionInInstantQueries(tc *at.TestCase, sut at.WriteQuerier) {
func testMillisecondPrecisionInInstantQueries(tc *at.TestCase, sut at.PrometheusWriteQuerier) {
t := tc.T()
type opts struct {
@@ -242,7 +238,7 @@ func testMillisecondPrecisionInInstantQueries(tc *at.TestCase, sut at.WriteQueri
wantSample *at.Sample
wantSamples []*at.Sample
}
f := func(sut at.APIQuerier, opts *opts) {
f := func(sut at.PrometheusQuerier, opts *opts) {
t.Helper()
wantResult := []*at.QueryResult{}
if opts.wantMetric != nil && (opts.wantSample != nil || len(opts.wantSamples) > 0) {
@@ -255,19 +251,19 @@ func testMillisecondPrecisionInInstantQueries(tc *at.TestCase, sut at.WriteQueri
tc.Assert(&at.AssertOptions{
Msg: "unexpected /api/v1/query response",
Got: func() any {
return sut.APIV1Query(t, opts.query, at.QueryOpts{
return sut.PrometheusAPIV1Query(t, opts.query, at.QueryOpts{
Time: opts.qtime,
Step: opts.step,
})
},
Want: &at.APIV1QueryResponse{Data: &at.QueryData{Result: wantResult}},
Want: &at.PrometheusAPIV1QueryResponse{Data: &at.QueryData{Result: wantResult}},
CmpOpts: []cmp.Option{
cmpopts.IgnoreFields(at.APIV1QueryResponse{}, "Status", "Data.ResultType"),
cmpopts.IgnoreFields(at.PrometheusAPIV1QueryResponse{}, "Status", "Data.ResultType"),
},
})
}
sut.APIV1ImportPrometheus(t, []string{
sut.PrometheusAPIV1ImportPrometheus(t, []string{
`series1{label="foo"} 10 1707123456700`, // 2024-02-05T08:57:36.700Z
`series1{label="foo"} 20 1707123456800`, // 2024-02-05T08:57:36.800Z
}, at.QueryOpts{})
@@ -326,7 +322,7 @@ func testMillisecondPrecisionInInstantQueries(tc *at.TestCase, sut at.WriteQueri
// Insert samples with different dates. The difference in ms between the two
// timestamps is 4236579304.
sut.APIV1ImportPrometheus(t, []string{
sut.PrometheusAPIV1ImportPrometheus(t, []string{
`series2{label="foo"} 10 1638564958042`, // 2021-12-03T20:55:58.042Z
`series2{label="foo"} 20 1642801537346`, // 2022-01-21T21:45:37.346Z
}, at.QueryOpts{})
@@ -371,131 +367,3 @@ func testMillisecondPrecisionInInstantQueries(tc *at.TestCase, sut at.WriteQueri
wantSample: &at.Sample{Timestamp: 1642801537346, Value: 2},
})
}
// TestVlsingleKeyConcepts verifies cases from https://docs.victoriametrics.com/victorialogs/keyconcepts/#data-model
// for vl-single.
func TestVlsingleKeyConcepts(t *testing.T) {
os.RemoveAll(t.Name())
tc := at.NewTestCase(t)
defer tc.Stop()
sut := tc.MustStartDefaultVlsingle()
type opts struct {
ingestRecords []string
ingestQueryArgs at.QueryOptsLogs
wantResponse *at.LogsQLQueryResponse
query string
selectQueryArgs at.QueryOptsLogs
}
f := func(opts *opts) {
t.Helper()
sut.JSONLineWrite(t, opts.ingestRecords, opts.ingestQueryArgs)
sut.ForceFlush(t)
got := sut.LogsQLQuery(t, opts.query, opts.selectQueryArgs)
assertLogsQLResponseEqual(t, got, opts.wantResponse)
}
// nested objects flatten
f(&opts{
ingestRecords: []string{
`{"_msg":"case 1","_time": "2025-06-05T14:30:19.088007Z", "host": {"name": "foobar","os": {"version": "1.2.3"}}}`,
`{"_msg":"case 1","_time": "2025-06-05T14:30:19.088007Z", "tags": ["foo", "bar"], "offset": 12345, "is_error": false}`,
},
wantResponse: &at.LogsQLQueryResponse{
LogLines: []string{
`{"_msg":"case 1","_stream":"{}","_time":"2025-06-05T14:30:19.088007Z","host.name":"foobar","host.os.version":"1.2.3"}`,
`{"_msg":"case 1","_stream":"{}","_time":"2025-06-05T14:30:19.088007Z","is_error":"false","offset":"12345","tags":"[\"foo\",\"bar\"]"}`,
},
},
query: "case 1",
})
// obtain _msg value from non-default field
f(&opts{
ingestRecords: []string{
`{"my_msg":"case 2","_time": "2025-06-05T14:30:19.088007Z", "foo":"bar"}`,
`{"my_msg_other":"case 2","_time": "2025-06-05T14:30:19.088007Z", "bar":"foo"}`,
},
ingestQueryArgs: at.QueryOptsLogs{
MessageField: "my_msg,my_msg_other",
},
query: "case 2",
wantResponse: &at.LogsQLQueryResponse{
LogLines: []string{
`{"_msg":"case 2","_stream":"{}","_time":"2025-06-05T14:30:19.088007Z","foo":"bar"}`,
`{"_msg":"case 2","_stream":"{}","_time":"2025-06-05T14:30:19.088007Z","bar":"foo"}`,
},
},
})
// populate stream fields
f(&opts{
ingestRecords: []string{
`{"my_msg":"case 3","_time": "2025-06-05T14:30:19.088007Z", "foo":"bar"}`,
`{"my_msg":"case 3","_time": "2025-06-05T14:30:19.088007Z", "bar":"foo"}`,
`{"my_msg":"case 3","_time": "2025-06-05T14:30:19.088007Z", "bar":"foo","foo":"bar","baz":"bar"}`,
},
ingestQueryArgs: at.QueryOptsLogs{
MessageField: "my_msg",
StreamFields: "foo,bar,baz",
},
wantResponse: &at.LogsQLQueryResponse{
LogLines: []string{
`{"_msg":"case 3","_stream":"{foo=\"bar\"}","_time":"2025-06-05T14:30:19.088007Z","foo":"bar"}`,
`{"_msg":"case 3","_stream":"{bar=\"foo\"}","_time":"2025-06-05T14:30:19.088007Z","bar":"foo"}`,
`{"_msg":"case 3","_stream":"{bar=\"foo\",baz=\"bar\",foo=\"bar\"}","_time":"2025-06-05T14:30:19.088007Z","bar":"foo","foo":"bar","baz":"bar"}`,
},
},
query: "case 3",
})
// obtain _time value from non-default field
f(&opts{
ingestRecords: []string{
`{"_msg":"case 4","my_time_field": "2025-06-05T14:30:19.088007Z", "foo":"bar"}`,
`{"_msg":"case 4","my_other_time_field": "2025-06-05T14:30:19.088007Z", "bar":"foo"}`,
},
ingestQueryArgs: at.QueryOptsLogs{
TimeField: "my_time_field,my_other_time_field",
},
wantResponse: &at.LogsQLQueryResponse{
LogLines: []string{
`{"_msg":"case 4","_stream":"{}","_time":"2025-06-05T14:30:19.088007Z","foo":"bar"}`,
`{"_msg":"case 4","_stream":"{}","_time":"2025-06-05T14:30:19.088007Z","bar":"foo"}`,
},
},
query: "case 4",
})
}
func assertLogsQLResponseEqual(t *testing.T, got, want *at.LogsQLQueryResponse) {
t.Helper()
sort.Strings(got.LogLines)
sort.Strings(want.LogLines)
if len(got.LogLines) != len(want.LogLines) {
t.Errorf("unexpected response len: -%d: +%d\n%s", len(want.LogLines), len(got.LogLines), strings.Join(got.LogLines, "\n"))
return
}
for i := range len(want.LogLines) {
gotLine, wantLine := got.LogLines[i], want.LogLines[i]
var gotLineJSON map[string]any
var wantLineJSON map[string]any
if err := json.Unmarshal([]byte(gotLine), &gotLineJSON); err != nil {
t.Errorf("cannot parse got line=%q: %s", gotLine, err)
return
}
if err := json.Unmarshal([]byte(wantLine), &wantLineJSON); err != nil {
t.Errorf("cannot parse want line=%q: %s", wantLine, err)
return
}
// stream_id is always unique, remove it from comparison
delete(gotLineJSON, "_stream_id")
delete(wantLineJSON, "_stream_id")
if diff := cmp.Diff(gotLineJSON, wantLineJSON); diff != "" {
t.Errorf("unexpected response (-want, +got):\n%s\n%s\n%s", diff, wantLine, gotLine)
return
}
}
}

View File

@@ -16,7 +16,7 @@ func TestSingleMaxIngestionRateIncrementsMetric(t *testing.T) {
tc := apptest.NewTestCase(t)
defer tc.Stop()
sut := tc.MustStartVmsingle("vmsingle", []string{"-maxIngestionRate=1"})
sut.APIV1ImportPrometheus(t, testData, apptest.QueryOpts{})
sut.PrometheusAPIV1ImportPrometheus(t, testData, apptest.QueryOpts{})
if got := sut.GetMetric(t, "vm_max_ingestion_rate_limit_reached_total"); got <= 0 {
t.Fatalf("Unexpected vm_max_ingestion_rate_limit_reached_total: got %f, want >0", got)
}
@@ -26,7 +26,7 @@ func TestSingleMaxIngestionRateDoesNotIncrementMetric(t *testing.T) {
tc := apptest.NewTestCase(t)
defer tc.Stop()
sut := tc.MustStartVmsingle("vmsingle", []string{"-maxIngestionRate=15"})
sut.APIV1ImportPrometheus(t, testData, apptest.QueryOpts{})
sut.PrometheusAPIV1ImportPrometheus(t, testData, apptest.QueryOpts{})
if got, want := sut.GetMetric(t, "vm_max_ingestion_rate_limit_reached_total"), 0.0; got != want {
t.Fatalf("Unexpected vm_max_ingestion_rate_limit_reached_total: got %f, want >0", got)
}

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