Repository: VictoriaMetrics/VictoriaMetrics
Pull request #6498 (2024-06-17 15:18:51)

vmui/logs: update footer links
### Describe Your Changes
Update the links in the footer for logs: [LogsQL](https://docs.victoriametrics.com/victorialogs/logsql/) and [Documentation](https://docs.victoriametrics.com/victorialogs/)




Patch:
diff --git a/README.md b/README.md
index d26c5ede4..e7df2d59b 100644
--- a/README.md
+++ b/README.md
@@ -3093,6 +3093,8 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
      Whether to disable automatic response cache reset if a sample with timestamp outside -search.cacheTimestampOffset is inserted into VictoriaMetrics
   -search.disableCache
      Whether to disable response caching. This may be useful when ingesting historical data. See https://docs.victoriametrics.com/#backfilling . See also -search.resetRollupResultCacheOnStartup
+  -search.disableImplicitConversion
+     Whether to return an error for queries that rely on implicit subquery conversions, see https://docs.victoriametrics.com/metricsql/#subqueries for details. See also -search.logImplicitConversion
   -search.graphiteMaxPointsPerSeries int
      The maximum number of points per series Graphite render API can return (default 1000000)
   -search.graphiteStorageStep duration
@@ -3101,6 +3103,8 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
      Whether to ignore match[], extra_filters[] and extra_label query args at /api/v1/labels and /api/v1/label/.../values . This may be useful for decreasing load on VictoriaMetrics when extra filters match too many time series. The downside is that superfluous labels or series could be returned, which do not match the extra filters. See also -search.maxLabelsAPISeries and -search.maxLabelsAPIDuration
   -search.latencyOffset duration
      The time when data points become visible in query results after the collection. It can be overridden on per-query basis via latency_offset arg. Too small value can result in incomplete last points for query results (default 30s)
+  -search.logImplicitConversion
+     Whether to log queries with implicit subquery conversions, see https://docs.victoriametrics.com/metricsql/#subqueries for details. Such conversion can be disabled using -search.disableImplicitConversion
   -search.logQueryMemoryUsage size
      Log query and increment vm_memory_intensive_queries_total metric each time the query requires more memory than specified by this flag. This may help detecting and optimizing heavy queries. Query logging is disabled by default. See also -search.logSlowQueryDuration and -search.maxMemoryPerQuery
      Supports the following optional suffixes for size values: KB, MB, GB, TB, KiB, MiB, GiB, TiB (default 0)
diff --git a/app/vlinsert/elasticsearch/elasticsearch.go b/app/vlinsert/elasticsearch/elasticsearch.go
index 9c6a2e1f0..fd5a7018e 100644
--- a/app/vlinsert/elasticsearch/elasticsearch.go
+++ b/app/vlinsert/elasticsearch/elasticsearch.go
@@ -97,12 +97,10 @@ func RequestHandler(path string, w http.ResponseWriter, r *http.Request) bool {
 			httpserver.Errorf(w, r, "%s", err)
 			return true
 		}
-		lr := logstorage.GetLogRows(cp.StreamFields, cp.IgnoreFields)
-		processLogMessage := cp.GetProcessLogMessageFunc(lr)
+		lmp := cp.NewLogMessageProcessor()
 		isGzip := r.Header.Get("Content-Encoding") == "gzip"
-		n, err := readBulkRequest(r.Body, isGzip, cp.TimeField, cp.MsgField, processLogMessage)
-		vlstorage.MustAddRows(lr)
-		logstorage.PutLogRows(lr)
+		n, err := readBulkRequest(r.Body, isGzip, cp.TimeField, cp.MsgField, lmp)
+		lmp.MustClose()
 		if err != nil {
 			logger.Warnf("cannot decode log message #%d in /_bulk request: %s, stream fields: %s", n, err, cp.StreamFields)
 			return true
@@ -131,9 +129,7 @@ var (
 	bulkRequestDuration = metrics.NewHistogram(`vl_http_request_duration_seconds{path="/insert/elasticsearch/_bulk"}`)
 )
 
-func readBulkRequest(r io.Reader, isGzip bool, timeField, msgField string,
-	processLogMessage func(timestamp int64, fields []logstorage.Field),
-) (int, error) {
+func readBulkRequest(r io.Reader, isGzip bool, timeField, msgField string, lmp insertutils.LogMessageProcessor) (int, error) {
 	// See https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html
 
 	if isGzip {
@@ -158,7 +154,7 @@ func readBulkRequest(r io.Reader, isGzip bool, timeField, msgField string,
 	n := 0
 	nCheckpoint := 0
 	for {
-		ok, err := readBulkLine(sc, timeField, msgField, processLogMessage)
+		ok, err := readBulkLine(sc, timeField, msgField, lmp)
 		wcr.DecConcurrency()
 		if err != nil || !ok {
 			rowsIngestedTotal.Add(n - nCheckpoint)
@@ -174,9 +170,7 @@ func readBulkRequest(r io.Reader, isGzip bool, timeField, msgField string,
 
 var lineBufferPool bytesutil.ByteBufferPool
 
-func readBulkLine(sc *bufio.Scanner, timeField, msgField string,
-	processLogMessage func(timestamp int64, fields []logstorage.Field),
-) (bool, error) {
+func readBulkLine(sc *bufio.Scanner, timeField, msgField string, lmp insertutils.LogMessageProcessor) (bool, error) {
 	var line []byte
 
 	// Read the command, must be "create" or "index"
@@ -222,7 +216,7 @@ func readBulkLine(sc *bufio.Scanner, timeField, msgField string,
 		ts = time.Now().UnixNano()
 	}
 	logstorage.RenameField(p.Fields, msgField, "_msg")
-	processLogMessage(ts, p.Fields)
+	lmp.AddRow(ts, p.Fields)
 	logstorage.PutJSONParser(p)
 
 	return true, nil
diff --git a/app/vlinsert/insertutils/common_params.go b/app/vlinsert/insertutils/common_params.go
index 6f808e00c..fbb3efb71 100644
--- a/app/vlinsert/insertutils/common_params.go
+++ b/app/vlinsert/insertutils/common_params.go
@@ -88,28 +88,62 @@ func GetCommonParamsForSyslog(tenantID logstorage.TenantID) *CommonParams {
 	return cp
 }
 
-// GetProcessLogMessageFunc returns a function, which adds parsed log messages to lr.
-func (cp *CommonParams) GetProcessLogMessageFunc(lr *logstorage.LogRows) func(timestamp int64, fields []logstorage.Field) {
-	return func(timestamp int64, fields []logstorage.Field) {
-		if len(fields) > *MaxFieldsPerLine {
-			rf := logstorage.RowFormatter(fields)
-			logger.Warnf("dropping log line with %d fields; it exceeds -insert.maxFieldsPerLine=%d; %s", len(fields), *MaxFieldsPerLine, rf)
-			rowsDroppedTotalTooManyFields.Inc()
-			return
-		}
-
-		lr.MustAdd(cp.TenantID, timestamp, fields)
-		if cp.Debug {
-			s := lr.GetRowString(0)
-			lr.ResetKeepSettings()
-			logger.Infof("remoteAddr=%s; requestURI=%s; ignoring log entry because of `debug` query arg: %s", cp.DebugRemoteAddr, cp.DebugRequestURI, s)
-			rowsDroppedTotalDebug.Inc()
-			return
-		}
-		if lr.NeedFlush() {
-			vlstorage.MustAddRows(lr)
-			lr.ResetKeepSettings()
-		}
+// LogMessageProcessor is an interface for log message processors.
+type LogMessageProcessor interface {
+	// AddRow must add row to the LogMessageProcessor with the given timestamp and the given fields.
+	//
+	// The LogMessageProcessor implementation cannot hold references to fields, since the caller can re-use them.
+	AddRow(timestamp int64, fields []logstorage.Field)
+
+	// MustClose() must flush all the remaining fields and free up resources occupied by LogMessageProcessor.
+	MustClose()
+}
+
+type logMessageProcessor struct {
+	cp *CommonParams
+	lr *logstorage.LogRows
+}
+
+// AddRow adds new log message to lmp with the given timestamp and fields.
+func (lmp *logMessageProcessor) AddRow(timestamp int64, fields []logstorage.Field) {
+	if len(fields) > *MaxFieldsPerLine {
+		rf := logstorage.RowFormatter(fields)
+		logger.Warnf("dropping log line with %d fields; it exceeds -insert.maxFieldsPerLine=%d; %s", len(fields), *MaxFieldsPerLine, rf)
+		rowsDroppedTotalTooManyFields.Inc()
+		return
+	}
+
+	lmp.lr.MustAdd(lmp.cp.TenantID, timestamp, fields)
+	if lmp.cp.Debug {
+		s := lmp.lr.GetRowString(0)
+		lmp.lr.ResetKeepSettings()
+		logger.Infof("remoteAddr=%s; requestURI=%s; ignoring log entry because of `debug` query arg: %s", lmp.cp.DebugRemoteAddr, lmp.cp.DebugRequestURI, s)
+		rowsDroppedTotalDebug.Inc()
+		return
+	}
+	if lmp.lr.NeedFlush() {
+		lmp.flush()
+	}
+}
+
+func (lmp *logMessageProcessor) flush() {
+	vlstorage.MustAddRows(lmp.lr)
+	lmp.lr.ResetKeepSettings()
+}
+
+// MustClose flushes the remaining data to the underlying storage and closes lmp.
+func (lmp *logMessageProcessor) MustClose() {
+	lmp.flush()
+	logstorage.PutLogRows(lmp.lr)
+	lmp.lr = nil
+}
+
+// NewLogMessageProcessor returns new LogMessageProcessor for the given cp.
+func (cp *CommonParams) NewLogMessageProcessor() LogMessageProcessor {
+	lr := logstorage.GetLogRows(cp.StreamFields, cp.IgnoreFields)
+	return &logMessageProcessor{
+		cp: cp,
+		lr: lr,
 	}
 }
 
diff --git a/app/vlinsert/insertutils/timestamp.go b/app/vlinsert/insertutils/timestamp.go
index b75e5f559..ec546bcd8 100644
--- a/app/vlinsert/insertutils/timestamp.go
+++ b/app/vlinsert/insertutils/timestamp.go
@@ -7,19 +7,19 @@ import (
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
 )
 
-// ExtractTimestampISO8601FromFields extracts ISO8601 timestamp in nanoseconds from the field with the name timeField at fields.
+// ExtractTimestampRFC3339NanoFromFields extracts RFC3339 timestamp in nanoseconds from the field with the name timeField at fields.
 //
 // The value for the timeField is set to empty string after returning from the function,
 // so it could be ignored during data ingestion.
 //
 // The current timestamp is returned if fields do not contain a field with timeField name or if the timeField value is empty.
-func ExtractTimestampISO8601FromFields(timeField string, fields []logstorage.Field) (int64, error) {
+func ExtractTimestampRFC3339NanoFromFields(timeField string, fields []logstorage.Field) (int64, error) {
 	for i := range fields {
 		f := &fields[i]
 		if f.Name != timeField {
 			continue
 		}
-		nsecs, ok := logstorage.TryParseTimestampISO8601(f.Value)
+		nsecs, ok := logstorage.TryParseTimestampRFC3339Nano(f.Value)
 		if !ok {
 			if f.Value == "0" || f.Value == "" {
 				return time.Now().UnixNano(), nil
diff --git a/app/vlinsert/jsonline/jsonline.go b/app/vlinsert/jsonline/jsonline.go
index 9eeb99a5c..10df87367 100644
--- a/app/vlinsert/jsonline/jsonline.go
+++ b/app/vlinsert/jsonline/jsonline.go
@@ -4,6 +4,7 @@ import (
 	"bufio"
 	"errors"
 	"fmt"
+	"io"
 	"net/http"
 	"time"
 
@@ -39,8 +40,6 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) {
 		httpserver.Errorf(w, r, "%s", err)
 		return
 	}
-	lr := logstorage.GetLogRows(cp.StreamFields, cp.IgnoreFields)
-	processLogMessage := cp.GetProcessLogMessageFunc(lr)
 
 	reader := r.Body
 	if r.Header.Get("Content-Encoding") == "gzip" {
@@ -53,7 +52,22 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) {
 		reader = zr
 	}
 
-	wcr := writeconcurrencylimiter.GetReader(reader)
+	lmp := cp.NewLogMessageProcessor()
+	err = processStreamInternal(reader, cp.TimeField, cp.MsgField, lmp)
+	lmp.MustClose()
+
+	if err != nil {
+		logger.Errorf("jsonline: %s", err)
+	} else {
+		// update requestDuration only for successfully parsed requests.
+		// There is no need in updating requestDuration for request errors,
+		// since their timings are usually much smaller than the timing for successful request parsing.
+		requestDuration.UpdateDuration(startTime)
+	}
+}
+
+func processStreamInternal(r io.Reader, timeField, msgField string, lmp insertutils.LogMessageProcessor) error {
+	wcr := writeconcurrencylimiter.GetReader(r)
 	defer writeconcurrencylimiter.PutReader(wcr)
 
 	lb := lineBufferPool.Get()
@@ -65,30 +79,21 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) {
 
 	n := 0
 	for {
-		ok, err := readLine(sc, cp.TimeField, cp.MsgField, processLogMessage)
+		ok, err := readLine(sc, timeField, msgField, lmp)
 		wcr.DecConcurrency()
 		if err != nil {
 			errorsTotal.Inc()
-			logger.Errorf("cannot read line #%d in /jsonline request: %s", n, err)
-			break
+			return fmt.Errorf("cannot read line #%d in /jsonline request: %s", n, err)
 		}
 		if !ok {
-			break
+			return nil
 		}
 		n++
 		rowsIngestedTotal.Inc()
 	}
-
-	vlstorage.MustAddRows(lr)
-	logstorage.PutLogRows(lr)
-
-	// update requestDuration only for successfully parsed requests.
-	// There is no need in updating requestDuration for request errors,
-	// since their timings are usually much smaller than the timing for successful request parsing.
-	requestDuration.UpdateDuration(startTime)
 }
 
-func readLine(sc *bufio.Scanner, timeField, msgField string, processLogMessage func(timestamp int64, fields []logstorage.Field)) (bool, error) {
+func readLine(sc *bufio.Scanner, timeField, msgField string, lmp insertutils.LogMessageProcessor) (bool, error) {
 	var line []byte
 	for len(line) == 0 {
 		if !sc.Scan() {
@@ -107,12 +112,12 @@ func readLine(sc *bufio.Scanner, timeField, msgField string, processLogMessage f
 	if err := p.ParseLogMessage(line); err != nil {
 		return false, fmt.Errorf("cannot parse json-encoded log entry: %w", err)
 	}
-	ts, err := insertutils.ExtractTimestampISO8601FromFields(timeField, p.Fields)
+	ts, err := insertutils.ExtractTimestampRFC3339NanoFromFields(timeField, p.Fields)
 	if err != nil {
 		return false, fmt.Errorf("cannot get timestamp: %w", err)
 	}
 	logstorage.RenameField(p.Fields, msgField, "_msg")
-	processLogMessage(ts, p.Fields)
+	lmp.AddRow(ts, p.Fields)
 	logstorage.PutJSONParser(p)
 
 	return true, nil
diff --git a/app/vlinsert/loki/loki.go b/app/vlinsert/loki/loki.go
index 95c5fd5e6..5900f2495 100644
--- a/app/vlinsert/loki/loki.go
+++ b/app/vlinsert/loki/loki.go
@@ -46,7 +46,7 @@ func getCommonParams(r *http.Request) (*insertutils.CommonParams, error) {
 	if cp.TenantID.AccountID == 0 && cp.TenantID.ProjectID == 0 {
 		org := r.Header.Get("X-Scope-OrgID")
 		if org != "" {
-			tenantID, err := logstorage.GetTenantIDFromString(org)
+			tenantID, err := logstorage.ParseTenantID(org)
 			if err != nil {
 				return nil, err
 			}
diff --git a/app/vlinsert/loki/loki_json.go b/app/vlinsert/loki/loki_json.go
index 96fd1a9d7..03890dea7 100644
--- a/app/vlinsert/loki/loki_json.go
+++ b/app/vlinsert/loki/loki_json.go
@@ -8,6 +8,7 @@ import (
 	"strconv"
 	"time"
 
+	"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutils"
 	"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/bytesutil"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
@@ -51,11 +52,9 @@ func handleJSON(r *http.Request, w http.ResponseWriter) {
 		httpserver.Errorf(w, r, "%s", err)
 		return
 	}
-	lr := logstorage.GetLogRows(cp.StreamFields, cp.IgnoreFields)
-	processLogMessage := cp.GetProcessLogMessageFunc(lr)
-	n, err := parseJSONRequest(data, processLogMessage)
-	vlstorage.MustAddRows(lr)
-	logstorage.PutLogRows(lr)
+	lmp := cp.NewLogMessageProcessor()
+	n, err := parseJSONRequest(data, lmp)
+	lmp.MustClose()
 	if err != nil {
 		httpserver.Errorf(w, r, "cannot parse Loki json request: %s", err)
 		return
@@ -75,7 +74,7 @@ var (
 	requestJSONDuration   = metrics.NewHistogram(`vl_http_request_duration_seconds{path="/insert/loki/api/v1/push",format="json"}`)
 )
 
-func parseJSONRequest(data []byte, processLogMessage func(timestamp int64, fields []logstorage.Field)) (int, error) {
+func parseJSONRequest(data []byte, lmp insertutils.LogMessageProcessor) (int, error) {
 	p := parserPool.Get()
 	defer parserPool.Put(p)
 	v, err := p.ParseBytes(data)
@@ -168,7 +167,7 @@ func parseJSONRequest(data []byte, processLogMessage func(timestamp int64, field
 				Name:  "_msg",
 				Value: bytesutil.ToUnsafeString(msg),
 			})
-			processLogMessage(ts, fields)
+			lmp.AddRow(ts, fields)
 		}
 		rowsIngested += len(lines)
 	}
diff --git a/app/vlinsert/loki/loki_protobuf.go b/app/vlinsert/loki/loki_protobuf.go
index 4860e6e12..b8c612a26 100644
--- a/app/vlinsert/loki/loki_protobuf.go
+++ b/app/vlinsert/loki/loki_protobuf.go
@@ -9,6 +9,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutils"
 	"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/bytesutil"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
@@ -43,11 +44,9 @@ func handleProtobuf(r *http.Request, w http.ResponseWriter) {
 		httpserver.Errorf(w, r, "%s", err)
 		return
 	}
-	lr := logstorage.GetLogRows(cp.StreamFields, cp.IgnoreFields)
-	processLogMessage := cp.GetProcessLogMessageFunc(lr)
-	n, err := parseProtobufRequest(data, processLogMessage)
-	vlstorage.MustAddRows(lr)
-	logstorage.PutLogRows(lr)
+	lmp := cp.NewLogMessageProcessor()
+	n, err := parseProtobufRequest(data, lmp)
+	lmp.MustClose()
 	if err != nil {
 		httpserver.Errorf(w, r, "cannot parse Loki protobuf request: %s", err)
 		return
@@ -67,7 +66,7 @@ var (
 	requestProtobufDuration   = metrics.NewHistogram(`vl_http_request_duration_seconds{path="/insert/loki/api/v1/push",format="protobuf"}`)
 )
 
-func parseProtobufRequest(data []byte, processLogMessage func(timestamp int64, fields []logstorage.Field)) (int, error) {
+func parseProtobufRequest(data []byte, lmp insertutils.LogMessageProcessor) (int, error) {
 	bb := bytesBufPool.Get()
 	defer bytesBufPool.Put(bb)
 
@@ -110,7 +109,7 @@ func parseProtobufRequest(data []byte, processLogMessage func(timestamp int64, f
 			if ts == 0 {
 				ts = currentTimestamp
 			}
-			processLogMessage(ts, fields)
+			lmp.AddRow(ts, fields)
 		}
 		rowsIngested += len(stream.Entries)
 	}
diff --git a/app/vlinsert/syslog/syslog.go b/app/vlinsert/syslog/syslog.go
index f5279d294..0a4f7cb03 100644
--- a/app/vlinsert/syslog/syslog.go
+++ b/app/vlinsert/syslog/syslog.go
@@ -8,6 +8,7 @@ import (
 	"fmt"
 	"io"
 	"net"
+	"strconv"
 	"strings"
 	"sync"
 	"sync/atomic"
@@ -25,25 +26,33 @@ import (
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/netutil"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/common"
+	"github.com/VictoriaMetrics/VictoriaMetrics/lib/slicesutil"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/writeconcurrencylimiter"
 	"github.com/VictoriaMetrics/metrics"
 )
 
 var (
-	syslogTenantID = flag.String("syslog.tenantID", "0:0", "TenantID for logs ingested via Syslog protocol. See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/")
 	syslogTimezone = flag.String("syslog.timezone", "Local", "Timezone to use when parsing timestamps in RFC3164 syslog messages. Timezone must be a valid IANA Time Zone. "+
 		"For example: America/New_York, Europe/Berlin, Etc/GMT+3 . See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/")
 
-	listenAddrTCP = flag.String("syslog.listenAddr.tcp", "", "Optional TCP address to listen to for Syslog messages. See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/")
-	listenAddrUDP = flag.String("syslog.listenAddr.udp", "", "Optional UDP address to listen to for Syslog messages. See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/")
+	syslogTenantIDTCP = flagutil.NewArrayString("syslog.tenantID.tcp", "TenantID for logs ingested via the corresponding -syslog.listenAddr.tcp. "+
+		"See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/")
+	syslogTenantIDUDP = flagutil.NewArrayString("syslog.tenantID.udp", "TenantID for logs ingested via the corresponding -syslog.listenAddr.udp. "+
+		"See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/")
+
+	listenAddrTCP = flagutil.NewArrayString("syslog.listenAddr.tcp", "Comma-separated list of TCP addresses to listen to for Syslog messages. "+
+		"See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/")
+	listenAddrUDP = flagutil.NewArrayString("syslog.listenAddr.udp", "Comma-separated list of UDP address to listen to for Syslog messages. "+
+		"See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/")
 
-	tlsEnable = flag.Bool("syslog.tls", false, "Whether to use TLS for receiving syslog messages at -syslog.listenAddr.tcp. -syslog.tlsCertFile and -syslog.tlsKeyFile must be set "+
-		"if -syslog.tls is set. See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/")
-	tlsCertFile = flag.String("syslog.tlsCertFile", "", "Path to file with TLS certificate for -syslog.listenAddr.tcp if -syslog.tls is set. "+
+	tlsEnable = flagutil.NewArrayBool("syslog.tls", "Whether to enable TLS for receiving syslog messages at the corresponding -syslog.listenAddr.tcp. "+
+		"The corresponding -syslog.tlsCertFile and -syslog.tlsKeyFile must be set if -syslog.tls is set. See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/")
+	tlsCertFile = flagutil.NewArrayString("syslog.tlsCertFile", "Path to file with TLS certificate for the corresponding -syslog.listenAddr.tcp if the corresponding -syslog.tls is set. "+
 		"Prefer ECDSA certs instead of RSA certs as RSA certs are slower. The provided certificate file is automatically re-read every second, so it can be dynamically updated. "+
 		"See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/")
-	tlsKeyFile = flag.String("syslog.tlsKeyFile", "", "Path to file with TLS key for  -syslog.listenAddr.tcp if -syslog.tls is set. "+
-		"The provided key file is automatically re-read every second, so it can be dynamically updated")
+	tlsKeyFile = flagutil.NewArrayString("syslog.tlsKeyFile", "Path to file with TLS key for the corresponding -syslog.listenAddr.tcp if the corresponding -syslog.tls is set. "+
+		"The provided key file is automatically re-read every second, so it can be dynamically updated. "+
+		"See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/")
 	tlsCipherSuites = flagutil.NewArrayString("syslog.tlsCipherSuites", "Optional list of TLS cipher suites for -syslog.listenAddr.tcp if -syslog.tls is set. "+
 		"See the list of supported cipher suites at https://pkg.go.dev/crypto/tls#pkg-constants . "+
 		"See also https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/")
@@ -51,7 +60,9 @@ var (
 		"Supported values: TLS10, TLS11, TLS12, TLS13. "+
 		"See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/")
 
-	compressMethod = flag.String("syslog.compressMethod", "", "Compression method for syslog messages received at -syslog.listenAddr.tcp and -syslog.listenAddr.udp. "+
+	compressMethodTCP = flagutil.NewArrayString("syslog.compressMethod.tcp", "Compression method for syslog messages received at the corresponding -syslog.listenAddr.tcp. "+
+		"Supported values: none, gzip, deflate. See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/")
+	compressMethodUDP = flagutil.NewArrayString("syslog.compressMethod.udp", "Compression method for syslog messages received at the corresponding -syslog.listenAddr.udp. "+
 		"Supported values: none, gzip, deflate. See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/")
 )
 
@@ -66,32 +77,20 @@ func MustInit() {
 	}
 	workersStopCh = make(chan struct{})
 
-	tenantID, err := logstorage.GetTenantIDFromString(*syslogTenantID)
-	if err != nil {
-		logger.Fatalf("cannot parse -syslog.tenantID=%q: %s", *syslogTenantID, err)
-	}
-	globalTenantID = tenantID
-
-	switch *compressMethod {
-	case "", "none", "gzip", "deflate":
-	default:
-		logger.Fatalf("unexpected -syslog.compressLevel=%q; supported values: none, gzip, deflate", *compressMethod)
-	}
-
-	if *listenAddrTCP != "" {
+	for argIdx, addr := range *listenAddrTCP {
 		workersWG.Add(1)
-		go func() {
-			runTCPListener(*listenAddrTCP)
+		go func(addr string, argIdx int) {
+			runTCPListener(addr, argIdx)
 			workersWG.Done()
-		}()
+		}(addr, argIdx)
 	}
 
-	if *listenAddrUDP != "" {
+	for argIdx, addr := range *listenAddrUDP {
 		workersWG.Add(1)
-		go func() {
-			runUDPListener(*listenAddrUDP)
+		go func(addr string, argIdx int) {
+			runUDPListener(addr, argIdx)
 			workersWG.Done()
-		}()
+		}(addr, argIdx)
 	}
 
 	currentYear := time.Now().Year()
@@ -124,7 +123,6 @@ func MustInit() {
 }
 
 var (
-	globalTenantID    logstorage.TenantID
 	globalCurrentYear atomic.Int64
 	globalTimezone    *time.Location
 )
@@ -141,15 +139,24 @@ func MustStop() {
 	workersStopCh = nil
 }
 
-func runUDPListener(addr string) {
+func runUDPListener(addr string, argIdx int) {
 	ln, err := net.ListenPacket(netutil.GetUDPNetwork(), addr)
 	if err != nil {
 		logger.Fatalf("cannot start UDP syslog server at %q: %s", addr, err)
 	}
 
+	tenantIDStr := syslogTenantIDUDP.GetOptionalArg(argIdx)
+	tenantID, err := logstorage.ParseTenantID(tenantIDStr)
+	if err != nil {
+		logger.Fatalf("cannot parse -syslog.tenantID.udp=%q for -syslog.listenAddr.udp=%q: %s", tenantIDStr, addr, err)
+	}
+
+	compressMethod := compressMethodUDP.GetOptionalArg(argIdx)
+	checkCompressMethod(compressMethod, addr, "udp")
+
 	doneCh := make(chan struct{})
 	go func() {
-		serveUDP(ln)
+		serveUDP(ln, tenantID, compressMethod)
 		close(doneCh)
 	}()
 
@@ -160,13 +167,15 @@ func runUDPListener(addr string) {
 	<-doneCh
 }
 
-func runTCPListener(addr string) {
+func runTCPListener(addr string, argIdx int) {
 	var tlsConfig *tls.Config
-	if *tlsEnable {
-		tc, err := netutil.GetServerTLSConfig(*tlsCertFile, *tlsKeyFile, *tlsMinVersion, *tlsCipherSuites)
+	if tlsEnable.GetOptionalArg(argIdx) {
+		certFile := tlsCertFile.GetOptionalArg(argIdx)
+		keyFile := tlsKeyFile.GetOptionalArg(argIdx)
+		tc, err := netutil.GetServerTLSConfig(certFile, keyFile, *tlsMinVersion, *tlsCipherSuites)
 		if err != nil {
 			logger.Fatalf("cannot load TLS cert from -syslog.tlsCertFile=%q, -syslog.tlsKeyFile=%q, -syslog.tlsMinVersion=%q, -syslog.tlsCipherSuites=%q: %s",
-				*tlsCertFile, *tlsKeyFile, *tlsMinVersion, *tlsCipherSuites, err)
+				certFile, keyFile, *tlsMinVersion, *tlsCipherSuites, err)
 		}
 		tlsConfig = tc
 	}
@@ -175,9 +184,18 @@ func runTCPListener(addr string) {
 		logger.Fatalf("syslog: cannot start TCP listener at %s: %s", addr, err)
 	}
 
+	tenantIDStr := syslogTenantIDTCP.GetOptionalArg(argIdx)
+	tenantID, err := logstorage.ParseTenantID(tenantIDStr)
+	if err != nil {
+		logger.Fatalf("cannot parse -syslog.tenantID.tcp=%q for -syslog.listenAddr.tcp=%q: %s", tenantIDStr, addr, err)
+	}
+
+	compressMethod := compressMethodTCP.GetOptionalArg(argIdx)
+	checkCompressMethod(compressMethod, addr, "tcp")
+
 	doneCh := make(chan struct{})
 	go func() {
-		serveTCP(ln)
+		serveTCP(ln, tenantID, compressMethod)
 		close(doneCh)
 	}()
 
@@ -188,7 +206,16 @@ func runTCPListener(addr string) {
 	<-doneCh
 }
 
-func serveUDP(ln net.PacketConn) {
+func checkCompressMethod(compressMethod, addr, protocol string) {
+	switch compressMethod {
+	case "", "none", "gzip", "deflate":
+		return
+	default:
+		logger.Fatalf("unsupported -syslog.compressMethod.%s=%q for -syslog.listenAddr.%s=%q; supported values: 'none', 'gzip', 'deflate'", protocol, compressMethod, protocol, addr)
+	}
+}
+
+func serveUDP(ln net.PacketConn, tenantID logstorage.TenantID, compressMethod string) {
 	gomaxprocs := cgroup.AvailableCPUs()
 	var wg sync.WaitGroup
 	localAddr := ln.LocalAddr()
@@ -196,7 +223,7 @@ func serveUDP(ln net.PacketConn) {
 		wg.Add(1)
 		go func() {
 			defer wg.Done()
-			cp := insertutils.GetCommonParamsForSyslog(globalTenantID)
+			cp := insertutils.GetCommonParamsForSyslog(tenantID)
 			var bb bytesutil.ByteBuffer
 			bb.B = bytesutil.ResizeNoCopyNoOverallocate(bb.B, 64*1024)
 			for {
@@ -221,7 +248,7 @@ func serveUDP(ln net.PacketConn) {
 				}
 				bb.B = bb.B[:n]
 				udpRequestsTotal.Inc()
-				if err := processStream(bb.NewReader(), cp); err != nil {
+				if err := processStream(bb.NewReader(), compressMethod, cp); err != nil {
 					logger.Errorf("syslog: cannot process UDP data from %s at %s: %s", remoteAddr, localAddr, err)
 				}
 			}
@@ -230,7 +257,7 @@ func serveUDP(ln net.PacketConn) {
 	wg.Wait()
 }
 
-func serveTCP(ln net.Listener) {
+func serveTCP(ln net.Listener, tenantID logstorage.TenantID, compressMethod string) {
 	var cm ingestserver.ConnsMap
 	cm.Init("syslog")
 
@@ -260,8 +287,8 @@ func serveTCP(ln net.Listener) {
 
 		wg.Add(1)
 		go func() {
-			cp := insertutils.GetCommonParamsForSyslog(globalTenantID)
-			if err := processStream(c, cp); err != nil {
+			cp := insertutils.GetCommonParamsForSyslog(tenantID)
+			if err := processStream(c, compressMethod, cp); err != nil {
 				logger.Errorf("syslog: cannot process TCP data at %q: %s", addr, err)
 			}
 
@@ -276,8 +303,20 @@ func serveTCP(ln net.Listener) {
 }
 
 // processStream parses a stream of syslog messages from r and ingests them into vlstorage.
-func processStream(r io.Reader, cp *insertutils.CommonParams) error {
-	switch *compressMethod {
+func processStream(r io.Reader, compressMethod string, cp *insertutils.CommonParams) error {
+	if err := vlstorage.CanWriteData(); err != nil {
+		return err
+	}
+
+	lmp := cp.NewLogMessageProcessor()
+	err := processStreamInternal(r, compressMethod, lmp)
+	lmp.MustClose()
+
+	return err
+}
+
+func processStreamInternal(r io.Reader, compressMethod string, lmp insertutils.LogMessageProcessor) error {
+	switch compressMethod {
 	case "", "none":
 	case "gzip":
 		zr, err := common.GetGzipReader(r)
@@ -292,12 +331,12 @@ func processStream(r io.Reader, cp *insertutils.CommonParams) error {
 		}
 		r = zr
 	default:
-		logger.Panicf("BUG: compressLevel=%q; supported values: none, gzip, deflate", *compressMethod)
+		logger.Panicf("BUG: unsupported compressMethod=%q; supported values: none, gzip, deflate", compressMethod)
 	}
 
-	err := processUncompressedStream(r, cp)
+	err := processUncompressedStream(r, lmp)
 
-	switch *compressMethod {
+	switch compressMethod {
 	case "gzip":
 		zr := r.(*gzip.Reader)
 		common.PutGzipReader(zr)
@@ -309,76 +348,154 @@ func processStream(r io.Reader, cp *insertutils.CommonParams) error {
 	return err
 }
 
-func processUncompressedStream(r io.Reader, cp *insertutils.CommonParams) error {
-	if err := vlstorage.CanWriteData(); err != nil {
-		return err
-	}
-	lr := logstorage.GetLogRows(cp.StreamFields, nil)
-	processLogMessage := cp.GetProcessLogMessageFunc(lr)
-
+func processUncompressedStream(r io.Reader, lmp insertutils.LogMessageProcessor) error {
 	wcr := writeconcurrencylimiter.GetReader(r)
 	defer writeconcurrencylimiter.PutReader(wcr)
 
-	lb := lineBufferPool.Get()
-	defer lineBufferPool.Put(lb)
-
-	lb.B = bytesutil.ResizeNoCopyNoOverallocate(lb.B, insertutils.MaxLineSizeBytes.IntN())
-	sc := bufio.NewScanner(wcr)
-	sc.Buffer(lb.B, len(lb.B))
+	slr := getSyslogLineReader(wcr)
+	defer putSyslogLineReader(slr)
 
 	n := 0
 	for {
-		currentYear := int(globalCurrentYear.Load())
-		ok, err := readLine(sc, currentYear, globalTimezone, processLogMessage)
+		ok := slr.nextLine()
 		wcr.DecConcurrency()
+		if !ok {
+			break
+		}
+
+		currentYear := int(globalCurrentYear.Load())
+		err := processLine(slr.line, currentYear, globalTimezone, lmp)
 		if err != nil {
 			errorsTotal.Inc()
 			return fmt.Errorf("cannot read line #%d: %s", n, err)
 		}
-		if !ok {
-			break
-		}
 		n++
 		rowsIngestedTotal.Inc()
 	}
+	return slr.Error()
+}
 
-	vlstorage.MustAddRows(lr)
-	logstorage.PutLogRows(lr)
+type syslogLineReader struct {
+	line []byte
 
-	return nil
+	br  *bufio.Reader
+	err error
 }
 
-func readLine(sc *bufio.Scanner, currentYear int, timezone *time.Location, processLogMessage func(timestamp int64, fields []logstorage.Field)) (bool, error) {
-	var line []byte
-	for len(line) == 0 {
-		if !sc.Scan() {
-			if err := sc.Err(); err != nil {
-				if errors.Is(err, bufio.ErrTooLong) {
-					return false, fmt.Errorf(`line size exceeds -insert.maxLineSizeBytes=%d`, insertutils.MaxLineSizeBytes.IntN())
-				}
-				return false, err
-			}
-			return false, nil
+func (slr *syslogLineReader) reset(r io.Reader) {
+	slr.line = slr.line[:0]
+	slr.br.Reset(r)
+	slr.err = nil
+}
+
+// Error returns the last error occurred in slr.
+func (slr *syslogLineReader) Error() error {
+	if slr.err == nil || slr.err == io.EOF {
+		return nil
+	}
+	return slr.err
+}
+
+// nextLine reads the next syslog line from slr and stores it at slr.line.
+//
+// false is returned if the next line cannot be read. Error() must be called in this case
+// in order to verify whether there is an error or just slr stream has been finished.
+func (slr *syslogLineReader) nextLine() bool {
+	if slr.err != nil {
+		return false
+	}
+
+	prefix, err := slr.br.ReadSlice(' ')
+	if err != nil {
+		if err != io.EOF {
+			slr.err = fmt.Errorf("cannot read message frame prefix: %w", err)
+			return false
 		}
-		line = sc.Bytes()
+		if len(prefix) == 0 {
+			slr.err = err
+			return false
+		}
+	}
+	// skip empty lines
+	for len(prefix) > 0 && prefix[0] == '\n' {
+		prefix = prefix[1:]
 	}
 
+	if prefix[0] >= '0' && prefix[0] <= '9' {
+		// This is octet-counting method. See https://www.ietf.org/archive/id/draft-gerhards-syslog-plain-tcp-07.html#msgxfer
+		msgLenStr := bytesutil.ToUnsafeString(prefix[:len(prefix)-1])
+		msgLen, err := strconv.ParseUint(msgLenStr, 10, 64)
+		if err != nil {
+			slr.err = fmt.Errorf("cannot parse message length from %q: %w", msgLenStr, err)
+			return false
+		}
+		if maxMsgLen := insertutils.MaxLineSizeBytes.IntN(); msgLen > uint64(maxMsgLen) {
+			slr.err = fmt.Errorf("cannot read message longer than %d bytes; msgLen=%d", maxMsgLen, msgLen)
+			return false
+		}
+		slr.line = slicesutil.SetLength(slr.line, int(msgLen))
+		if _, err := io.ReadFull(slr.br, slr.line); err != nil {
+			slr.err = fmt.Errorf("cannot read message with size %d bytes: %w", msgLen, err)
+			return false
+		}
+		return true
+	}
+
+	// This is octet-stuffing method. See https://www.ietf.org/archive/id/draft-gerhards-syslog-plain-tcp-07.html#octet-stuffing-legacy
+	slr.line = append(slr.line[:0], prefix...)
+	for {
+		line, err := slr.br.ReadSlice('\n')
+		if err == nil {
+			slr.line = append(slr.line, line[:len(line)-1]...)
+			return true
+		}
+		if err == io.EOF {
+			slr.line = append(slr.line, line...)
+			return true
+		}
+		if err == bufio.ErrBufferFull {
+			slr.line = append(slr.line, line...)
+			continue
+		}
+		slr.err = fmt.Errorf("cannot read message in octet-stuffing method: %w", err)
+		return false
+	}
+}
+
+func getSyslogLineReader(r io.Reader) *syslogLineReader {
+	v := syslogLineReaderPool.Get()
+	if v == nil {
+		br := bufio.NewReaderSize(r, 64*1024)
+		return &syslogLineReader{
+			br: br,
+		}
+	}
+	slr := v.(*syslogLineReader)
+	slr.reset(r)
+	return slr
+}
+
+func putSyslogLineReader(slr *syslogLineReader) {
+	syslogLineReaderPool.Put(slr)
+}
+
+var syslogLineReaderPool sync.Pool
+
+func processLine(line []byte, currentYear int, timezone *time.Location, lmp insertutils.LogMessageProcessor) error {
 	p := logstorage.GetSyslogParser(currentYear, timezone)
 	lineStr := bytesutil.ToUnsafeString(line)
 	p.Parse(lineStr)
-	ts, err := insertutils.ExtractTimestampISO8601FromFields("timestamp", p.Fields)
+	ts, err := insertutils.ExtractTimestampRFC3339NanoFromFields("timestamp", p.Fields)
 	if err != nil {
-		return false, fmt.Errorf("cannot get timestamp from syslog line %q: %w", line, err)
+		return fmt.Errorf("cannot get timestamp from syslog line %q: %w", line, err)
 	}
 	logstorage.RenameField(p.Fields, "message", "_msg")
-	processLogMessage(ts, p.Fields)
+	lmp.AddRow(ts, p.Fields)
 	logstorage.PutSyslogParser(p)
 
-	return true, nil
+	return nil
 }
 
-var lineBufferPool bytesutil.ByteBufferPool
-
 var (
 	rowsIngestedTotal = metrics.NewCounter(`vl_rows_ingested_total{type="syslog"}`)
 
diff --git a/app/vmalert-tool/main.go b/app/vmalert-tool/main.go
index 790cebd8b..46c90101d 100644
--- a/app/vmalert-tool/main.go
+++ b/app/vmalert-tool/main.go
@@ -26,8 +26,14 @@ func main() {
 				UsageText: "More info in https://docs.victoriametrics.com/vmalert-tool.html#Unit-testing-for-rules",
 				Flags: []cli.Flag{
 					&cli.StringSliceFlag{
-						Name:     "files",
-						Usage:    "files to run unittest with. Supports an array of values separated by comma or specified via multiple flags.",
+						Name: "files",
+						Usage: `File path or http url with test files. Supports an array of values separated by comma or specified via multiple flags.
+						Supports hierarchical patterns and regexpes.
+Examples:
+ -files="/path/to/file". Path to a single test file.
+ -files="http://<some-server-addr>/path/to/test.yaml". HTTP URL to a test file.
+ -files="dir/**/*.yaml". Includes all the .yaml files in "dir" subfolders recursively.
+ `,
 						Required: true,
 					},
 					&cli.BoolFlag{
diff --git a/app/vmalert/config/config.go b/app/vmalert/config/config.go
index 4d8232d06..d2c07469c 100644
--- a/app/vmalert/config/config.go
+++ b/app/vmalert/config/config.go
@@ -234,7 +234,7 @@ func ParseSilent(pathPatterns []string, validateTplFn ValidateTplFn, validateExp
 	cLogger.Suppress(true)
 	defer cLogger.Suppress(false)
 
-	files, err := readFromFS(pathPatterns)
+	files, err := ReadFromFS(pathPatterns)
 	if err != nil {
 		return nil, fmt.Errorf("failed to read from the config: %w", err)
 	}
@@ -243,7 +243,7 @@ func ParseSilent(pathPatterns []string, validateTplFn ValidateTplFn, validateExp
 
 // Parse parses rule configs from given file patterns
 func Parse(pathPatterns []string, validateTplFn ValidateTplFn, validateExpressions bool) ([]Group, error) {
-	files, err := readFromFS(pathPatterns)
+	files, err := ReadFromFS(pathPatterns)
 	if err != nil {
 		return nil, fmt.Errorf("failed to read from the config: %w", err)
 	}
diff --git a/app/vmalert/config/fs.go b/app/vmalert/config/fs.go
index 2fc068370..5f9854c75 100644
--- a/app/vmalert/config/fs.go
+++ b/app/vmalert/config/fs.go
@@ -32,14 +32,14 @@ var (
 	fsRegistry   = make(map[string]FS)
 )
 
-// readFromFS parses the given path list and inits FS for each item.
-// Once initialed, readFromFS will try to read and return files from each FS.
-// readFromFS returns an error if at least one FS failed to init.
+// ReadFromFS parses the given path list and inits FS for each item.
+// Once initialed, ReadFromFS will try to read and return files from each FS.
+// ReadFromFS returns an error if at least one FS failed to init.
 // The function can be called multiple times but each unique path
 // will be initialed only once.
 //
 // It is allowed to mix different FS types in path list.
-func readFromFS(paths []string) (map[string][]byte, error) {
+func ReadFromFS(paths []string) (map[string][]byte, error) {
 	var err error
 	result := make(map[string][]byte)
 	for _, path := range paths {
diff --git a/app/vmui/packages/vmui/src/api/logs.ts b/app/vmui/packages/vmui/src/api/logs.ts
index a5d62126f..35b268b77 100644
--- a/app/vmui/packages/vmui/src/api/logs.ts
+++ b/app/vmui/packages/vmui/src/api/logs.ts
@@ -1,2 +1,5 @@
 export const getLogsUrl = (server: string): string =>
   `${server}/select/logsql/query`;
+
+export const getLogHitsUrl = (server: string): string =>
+  `${server}/select/logsql/hits`;
diff --git a/app/vmui/packages/vmui/src/api/types.ts b/app/vmui/packages/vmui/src/api/types.ts
index eee701f93..e9765ee10 100644
--- a/app/vmui/packages/vmui/src/api/types.ts
+++ b/app/vmui/packages/vmui/src/api/types.ts
@@ -34,3 +34,11 @@ export interface Logs {
   _time: string;
   [key: string]: string;
 }
+
+export interface LogHits {
+  timestamps: string[];
+  values: number[];
+  fields: {
+    [key: string]: string;
+  };
+}
diff --git a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsChart.tsx b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsChart.tsx
new file mode 100644
index 000000000..748bf46f2
--- /dev/null
+++ b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/BarHitsChart.tsx
@@ -0,0 +1,76 @@
+import React, { FC, useRef, useState } from "preact/compat";
+import "./style.scss";
+import "uplot/dist/uPlot.min.css";
+import useElementSize from "../../../hooks/useElementSize";
+import uPlot, { AlignedData } from "uplot";
+import { useEffect } from "react";
+import useBarHitsOptions from "./hooks/useBarHitsOptions";
+import TooltipBarHitsChart from "./TooltipBarHitsChart";
+import { TimeParams } from "../../../types";
+import usePlotScale from "../../../hooks/uplot/usePlotScale";
+import useReadyChart from "../../../hooks/uplot/useReadyChart";
+import useZoomChart from "../../../hooks/uplot/useZoomChart";
+import classNames from "classnames";
+
+interface Props {
+  data: AlignedData;
+  period: TimeParams;
+  setPeriod: ({ from, to }: { from: Date, to: Date }) => void;
+}
+
+const BarHitsChart: FC<Props> = ({ data, period, setPeriod }) => {
+  const [containerRef, containerSize] = useElementSize();
+  const uPlotRef = useRef<HTMLDivElement>(null);
+  const [uPlotInst, setUPlotInst] = useState<uPlot>();
+
+  const { xRange, setPlotScale } = usePlotScale({ period, setPeriod });
+  const { onReadyChart, isPanning } = useReadyChart(setPlotScale);
+  useZoomChart({ uPlotInst, xRange, setPlotScale });
+  const { options, focusDataIdx } = useBarHitsOptions({ xRange, containerSize, onReadyChart, setPlotScale });
+
+  useEffect(() => {
+    if (!uPlotRef.current) return;
+    const uplot = new uPlot(options, data, uPlotRef.current);
+    setUPlotInst(uplot);
+    return () => uplot.destroy();
+  }, [uPlotRef.current, options]);
+
+  useEffect(() => {
+    if (!uPlotInst) return;
+    uPlotInst.scales.x.range = () => [xRange.min, xRange.max];
+    uPlotInst.redraw();
+  }, [xRange]);
+
+  useEffect(() => {
+    if (!uPlotInst) return;
+    uPlotInst.setSize(containerSize);
+    uPlotInst.redraw();
+  }, [containerSize]);
+
+  useEffect(() => {
+    if (!uPlotInst) return;
+    uPlotInst.setData(data);
+    uPlotInst.redraw();
+  }, [data]);
+
+  return (
+    <div
+      className={classNames({
+        "vm-bar-hits-chart": true,
+        "vm-bar-hits-chart_panning": isPanning
+      })}
+      ref={containerRef}
+    >
+      <div
+        className="vm-line-chart__u-plot"
+        ref={uPlotRef}
+      />
+      <TooltipBarHitsChart
+        uPlotInst={uPlotInst}
+        focusDataIdx={focusDataIdx}
+      />
+    </div>
+  );
+};
+
+export default BarHitsChart;
diff --git a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/TooltipBarHitsChart.tsx b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/TooltipBarHitsChart.tsx
new file mode 100644
index 000000000..2d96c32d0
--- /dev/null
+++ b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/TooltipBarHitsChart.tsx
@@ -0,0 +1,89 @@
+import React, { FC, useMemo, useRef } from "preact/compat";
+import uPlot from "uplot";
+import dayjs from "dayjs";
+import { DATE_TIME_FORMAT } from "../../../constants/date";
+import classNames from "classnames";
+import "./style.scss";
+import "../../../components/Chart/ChartTooltip/style.scss";
+
+interface Props {
+  uPlotInst?: uPlot;
+  focusDataIdx: number
+}
+
+const TooltipBarHitsChart: FC<Props> = ({ focusDataIdx, uPlotInst }) => {
+  const tooltipRef = useRef<HTMLDivElement>(null);
+
+  const tooltipData = useMemo(() => {
+    const value = uPlotInst?.data?.[1]?.[focusDataIdx];
+    const timestamp = uPlotInst?.data?.[0]?.[focusDataIdx] || 0;
+    const top = uPlotInst?.valToPos?.((value || 0), "y") || 0;
+    const left = uPlotInst?.valToPos?.(timestamp, "x") || 0;
+
+    return {
+      point: { top, left },
+      value,
+      timestamp: dayjs(timestamp * 1000).tz().format(DATE_TIME_FORMAT),
+    };
+  }, [focusDataIdx, uPlotInst]);
+
+  const tooltipPosition = useMemo(() => {
+    if (!uPlotInst || !tooltipData.value || !tooltipRef.current) return;
+
+    const { top, left } = tooltipData.point;
+    const uPlotPosition = {
+      left: parseFloat(uPlotInst.over.style.left),
+      top: parseFloat(uPlotInst.over.style.top)
+    };
+
+    const {
+      width: uPlotWidth,
+      height: uPlotHeight
+    } = uPlotInst.over.getBoundingClientRect();
+
+    const {
+      width: tooltipWidth,
+      height: tooltipHeight
+    } = tooltipRef.current.getBoundingClientRect();
+
+    const margin = 10;
+    const overflowX = left + tooltipWidth >= uPlotWidth ? tooltipWidth + (2 * margin) : 0;
+    const overflowY = top + tooltipHeight >= uPlotHeight ? tooltipHeight + (2 * margin) : 0;
+
+    const position = {
+      top: top + uPlotPosition.top + margin - overflowY,
+      left: left + uPlotPosition.left + margin - overflowX
+    };
+
+    if (position.left < 0) position.left = 20;
+    if (position.top < 0) position.top = 20;
+
+    return position;
+  }, [tooltipData, uPlotInst, tooltipRef.current]);
+
+  return (
+    <div
+      className={classNames({
+        "vm-chart-tooltip": true,
+        "vm-bar-hits-chart-tooltip": true,
+        "vm-bar-hits-chart-tooltip_visible": focusDataIdx !== -1
+      })}
+      ref={tooltipRef}
+      style={tooltipPosition}
+    >
+      <div className="vm-chart-tooltip-data">
+        Count of records:
+        <p className="vm-chart-tooltip-data__value">
+          <b>{tooltipData.value}</b>
+        </p>
+      </div>
+      <div className="vm-chart-tooltip-header">
+        <div className="vm-chart-tooltip-header__title">
+          {tooltipData.timestamp}
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default TooltipBarHitsChart;
diff --git a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/hooks/useBarHitsOptions.ts b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/hooks/useBarHitsOptions.ts
new file mode 100644
index 000000000..f72b6cacd
--- /dev/null
+++ b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/hooks/useBarHitsOptions.ts
@@ -0,0 +1,74 @@
+import { useMemo, useState } from "preact/compat";
+import { getAxes, handleDestroy, setSelect } from "../../../../utils/uplot";
+import dayjs from "dayjs";
+import { dateFromSeconds, formatDateForNativeInput } from "../../../../utils/time";
+import uPlot, { Options } from "uplot";
+import { getCssVariable } from "../../../../utils/theme";
+import { barPaths } from "../../../../utils/uplot/bars";
+import { useAppState } from "../../../../state/common/StateContext";
+import { MinMax, SetMinMax } from "../../../../types";
+
+interface UseGetBarHitsOptionsArgs {
+  xRange: MinMax;
+  containerSize: { width: number, height: number };
+  setPlotScale: SetMinMax;
+  onReadyChart: (u: uPlot) => void;
+}
+
+const useBarHitsOptions = ({ xRange, containerSize, onReadyChart, setPlotScale }: UseGetBarHitsOptionsArgs) => {
+  const { isDarkTheme } = useAppState();
+
+  const [focusDataIdx, setFocusDataIdx] = useState(-1);
+
+  const series = useMemo(() => [
+    {},
+    {
+      label: "y",
+      width: 1,
+      stroke: getCssVariable("color-log-hits-bar"),
+      fill: getCssVariable("color-log-hits-bar"),
+      paths: barPaths,
+    }
+  ], [isDarkTheme]);
+
+  const setCursor = (u: uPlot) => {
+    const dataIdx = u.cursor.idx ?? -1;
+    setFocusDataIdx(dataIdx);
+  };
+
+  const options: Options = useMemo(() => ({
+    series,
+    width: containerSize.width || (window.innerWidth / 2),
+    height: containerSize.height || 200,
+    cursor: {
+      points: {
+        width: (u, seriesIdx, size) => size / 4,
+        size: (u, seriesIdx) => (u.series?.[seriesIdx]?.points?.size || 1) * 2.5,
+        stroke: (u, seriesIdx) => `${series?.[seriesIdx]?.stroke || "#ffffff"}`,
+        fill: () => "#ffffff",
+      },
+    },
+    scales: {
+      x: {
+        time: true,
+        range: () => [xRange.min, xRange.max]
+      }
+    },
+    hooks: {
+      ready: [onReadyChart],
+      setCursor: [setCursor],
+      setSelect: [setSelect(setPlotScale)],
+      destroy: [handleDestroy],
+    },
+    legend: { show: false },
+    axes: getAxes([{}, { scale: "y" }]),
+    tzDate: ts => dayjs(formatDateForNativeInput(dateFromSeconds(ts))).local().toDate(),
+  }), [isDarkTheme]);
+
+  return {
+    options,
+    focusDataIdx,
+  };
+};
+
+export default useBarHitsOptions;
diff --git a/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/style.scss b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/style.scss
new file mode 100644
index 000000000..509a5517e
--- /dev/null
+++ b/app/vmui/packages/vmui/src/components/Chart/BarHitsChart/style.scss
@@ -0,0 +1,22 @@
+@use "src/styles/variables" as *;
+
+.vm-bar-hits-chart {
+  height: 100%;
+  width: 100%;
+
+  &_panning {
+    pointer-events: none;
+  }
+
+  &-tooltip {
+    opacity: 0;
+    pointer-events: none;
+    width: 240px;
+    gap: $padding-small;
+
+    &_visible {
+      opacity: 1;
+      pointer-events: auto;
+    }
+  }
+}
diff --git a/app/vmui/packages/vmui/src/constants/footerLinks.ts b/app/vmui/packages/vmui/src/constants/footerLinks.ts
new file mode 100644
index 000000000..71b847915
--- /dev/null
+++ b/app/vmui/packages/vmui/src/constants/footerLinks.ts
@@ -0,0 +1,35 @@
+import { CodeIcon, IssueIcon, WikiIcon } from "../components/Main/Icons";
+
+const issueLink = {
+  href: "https://github.com/VictoriaMetrics/VictoriaMetrics/issues/new/choose",
+  Icon: IssueIcon,
+  title: "Create an issue",
+};
+
+export const footerLinksByDefault = [
+  {
+    href: "https://docs.victoriametrics.com/MetricsQL.html",
+    Icon: CodeIcon,
+    title: "MetricsQL",
+  },
+  {
+    href: "https://docs.victoriametrics.com/#vmui",
+    Icon: WikiIcon,
+    title: "Documentation",
+  },
+  issueLink
+];
+
+export const footerLinksToLogs = [
+  {
+    href: "https://docs.victoriametrics.com/victorialogs/logsql/",
+    Icon: CodeIcon,
+    title: "LogsQL",
+  },
+  {
+    href: "https://docs.victoriametrics.com/victorialogs/",
+    Icon: WikiIcon,
+    title: "Documentation",
+  },
+  issueLink
+];
diff --git a/app/vmui/packages/vmui/src/constants/logs.ts b/app/vmui/packages/vmui/src/constants/logs.ts
new file mode 100644
index 000000000..4ad19c3e1
--- /dev/null
+++ b/app/vmui/packages/vmui/src/constants/logs.ts
@@ -0,0 +1,2 @@
+export const LOGS_ENTRIES_LIMIT = 50;
+export const LOGS_BARS_VIEW = 20;
diff --git a/app/vmui/packages/vmui/src/constants/palette.ts b/app/vmui/packages/vmui/src/constants/palette.ts
index abb11bfed..11f78d637 100644
--- a/app/vmui/packages/vmui/src/constants/palette.ts
+++ b/app/vmui/packages/vmui/src/constants/palette.ts
@@ -14,7 +14,8 @@ export const darkPalette = {
   "box-shadow": "rgba(0, 0, 0, 0.16) 1px 2px 6px",
   "box-shadow-popper": "rgba(0, 0, 0, 0.2) 0px 2px 8px 0px",
   "border-divider": "1px solid rgba(99, 110, 123, 0.5)",
-  "color-hover-black": "rgba(0, 0, 0, 0.12)"
+  "color-hover-black": "rgba(0, 0, 0, 0.12)",
+  "color-log-hits-bar": "rgba(255, 255, 255, 0.18)"
 };
 
 export const lightPalette = {
@@ -33,5 +34,6 @@ export const lightPalette = {
   "box-shadow": "rgba(0, 0, 0, 0.08) 1px 2px 6px",
   "box-shadow-popper": "rgba(0, 0, 0, 0.1) 0px 2px 8px 0px",
   "border-divider": "1px solid rgba(0, 0, 0, 0.15)",
-  "color-hover-black": "rgba(0, 0, 0, 0.06)"
+  "color-hover-black": "rgba(0, 0, 0, 0.06)",
+  "color-log-hits-bar": "rgba(0, 0, 0, 0.18)"
 };
diff --git a/app/vmui/packages/vmui/src/layouts/Footer/Footer.tsx b/app/vmui/packages/vmui/src/layouts/Footer/Footer.tsx
index 58058926a..81a091a22 100644
--- a/app/vmui/packages/vmui/src/layouts/Footer/Footer.tsx
+++ b/app/vmui/packages/vmui/src/layouts/Footer/Footer.tsx
@@ -1,8 +1,17 @@
 import React, { FC, memo } from "preact/compat";
-import { CodeIcon, IssueIcon, LogoShortIcon, WikiIcon } from "../../components/Main/Icons";
+import { LogoShortIcon } from "../../components/Main/Icons";
 import "./style.scss";
+import { footerLinksByDefault } from "../../constants/footerLinks";
 
-const Footer: FC = memo(() => {
+interface Props {
+  links?: {
+    href: string;
+    Icon: FC;
+    title: string;
+  }[]
+}
+
+const Footer: FC<Props> = memo(({ links = footerLinksByDefault }) => {
   const copyrightYears = `2019-${new Date().getFullYear()}`;
 
   return <footer className="vm-footer">
@@ -15,33 +24,18 @@ const Footer: FC = memo(() => {
       <LogoShortIcon/>
       victoriametrics.com
     </a>
-    <a
-      className="vm-link vm-footer__link"
-      target="_blank"
-      href="https://docs.victoriametrics.com/MetricsQL.html"
-      rel="help noreferrer"
-    >
-      <CodeIcon/>
-      MetricsQL
-    </a>
-    <a
-      className="vm-link vm-footer__link"
-      target="_blank"
-      href="https://docs.victoriametrics.com/#vmui"
-      rel="help noreferrer"
-    >
-      <WikiIcon/>
-      Documentation
-    </a>
-    <a
-      className="vm-link vm-footer__link"
-      target="_blank"
-      href="https://github.com/VictoriaMetrics/VictoriaMetrics/issues/new/choose"
-      rel="noreferrer"
-    >
-      <IssueIcon/>
-      Create an issue
-    </a>
+    {links.map(({ href, Icon, title }) => (
+      <a
+        className="vm-link vm-footer__link"
+        target="_blank"
+        href={href}
+        rel="help noreferrer"
+        key={`${href}-${title}`}
+      >
+        <Icon/>
+        {title}
+      </a>
+    ))}
     <div className="vm-footer__copyright">
       &copy; {copyrightYears} VictoriaMetrics
     </div>
diff --git a/app/vmui/packages/vmui/src/layouts/LogsLayout/LogsLayout.tsx b/app/vmui/packages/vmui/src/layouts/LogsLayout/LogsLayout.tsx
index 3d6284993..757fb8c08 100644
--- a/app/vmui/packages/vmui/src/layouts/LogsLayout/LogsLayout.tsx
+++ b/app/vmui/packages/vmui/src/layouts/LogsLayout/LogsLayout.tsx
@@ -9,6 +9,7 @@ import router, { routerOptions } from "../../router";
 import useDeviceDetect from "../../hooks/useDeviceDetect";
 import ControlsLogsLayout from "./ControlsLogsLayout";
 import useFetchDefaultTimezone from "../../hooks/useFetchDefaultTimezone";
+import { footerLinksToLogs } from "../../constants/footerLinks";
 
 const LogsLayout: FC = () => {
   const appModeEnable = getAppModeEnable();
@@ -36,7 +37,7 @@ const LogsLayout: FC = () => {
     >
       <Outlet/>
     </div>
-    {!appModeEnable && <Footer/>}
+    {!appModeEnable && <Footer links={footerLinksToLogs}/>}
   </section>;
 };
 
diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogs.tsx b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogs.tsx
index 9dbebedb6..8b289f4f5 100644
--- a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogs.tsx
+++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogs.tsx
@@ -12,9 +12,12 @@ import { ErrorTypes } from "../../types";
 import { useState } from "react";
 import { useTimeState } from "../../state/time/TimeStateContext";
 import { getFromStorage, saveToStorage } from "../../utils/storage";
+import ExploreLogsBarChart from "./ExploreLogsBarChart/ExploreLogsBarChart";
+import { useFetchLogHits } from "./hooks/useFetchLogHits";
+import { LOGS_ENTRIES_LIMIT } from "../../constants/logs";
 
 const storageLimit = Number(getFromStorage("LOGS_LIMIT"));
-const defaultLimit = isNaN(storageLimit) ? 50 : storageLimit;
+const defaultLimit = isNaN(storageLimit) ? LOGS_ENTRIES_LIMIT : storageLimit;
 
 const ExploreLogs: FC = () => {
   const { serverUrl } = useAppState();
@@ -24,6 +27,7 @@ const ExploreLogs: FC = () => {
   const [limit, setLimit] = useStateSearchParams(defaultLimit, "limit");
   const [query, setQuery] = useStateSearchParams("*", "query");
   const { logs, isLoading, error, fetchLogs } = useFetchLogs(serverUrl, query, limit);
+  const { fetchLogHits, ...dataLogHits } = useFetchLogHits(serverUrl, query);
   const [queryError, setQueryError] = useState<ErrorTypes | string>("");
   const [loaded, isLoaded] = useState(false);
   const [markdownParsing, setMarkdownParsing] = useState(getFromStorage("LOGS_MARKDOWN") === "true");
@@ -37,6 +41,7 @@ const ExploreLogs: FC = () => {
     fetchLogs().then(() => {
       isLoaded(true);
     });
+    fetchLogHits();
 
     setSearchParamsFromKeys( {
       query,
@@ -79,6 +84,11 @@ const ExploreLogs: FC = () => {
       />
       {isLoading && <Spinner />}
       {error && <Alert variant="error">{error}</Alert>}
+      <ExploreLogsBarChart
+        query={query}
+        loaded={loaded}
+        {...dataLogHits}
+      />
       <ExploreLogsBody
         data={logs}
         loaded={loaded}
diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBarChart/ExploreLogsBarChart.tsx b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBarChart/ExploreLogsBarChart.tsx
new file mode 100644
index 000000000..1b367d9ca
--- /dev/null
+++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBarChart/ExploreLogsBarChart.tsx
@@ -0,0 +1,82 @@
+import React, { FC, useMemo } from "preact/compat";
+import "./style.scss";
+import useDeviceDetect from "../../../hooks/useDeviceDetect";
+import classNames from "classnames";
+import { LogHits } from "../../../api/types";
+import dayjs from "dayjs";
+import { useTimeDispatch, useTimeState } from "../../../state/time/TimeStateContext";
+import { AlignedData } from "uplot";
+import BarHitsChart from "../../../components/Chart/BarHitsChart/BarHitsChart";
+import Alert from "../../../components/Main/Alert/Alert";
+
+interface Props {
+  query: string;
+  logHits: LogHits[];
+  error?: string;
+  isLoading: boolean;
+  loaded: boolean;
+}
+
+const ExploreLogsBarChart: FC<Props> = ({ logHits, error, loaded }) => {
+  const { isMobile } = useDeviceDetect();
+  const { period } = useTimeState();
+  const timeDispatch = useTimeDispatch();
+
+  const data = useMemo(() => {
+    const hits = logHits[0];
+    if (!hits) return [[], []] as AlignedData;
+    const { values, timestamps } = hits;
+    const xAxis = timestamps.map(t => t ? dayjs(t).unix() : null).filter(Boolean);
+    const yAxis = values.map(v => v || null);
+    return [xAxis, yAxis] as AlignedData;
+  }, [logHits]);
+
+  const noDataMessage: string = useMemo(() => {
+    const noData = data.every(d => d.length === 0);
+    const noTimestamps = data[0].length === 0;
+    const noValues = data[1].length === 0;
+    if (noData) {
+      return "No logs volume available\nNo volume information available for the current queries and time range.";
+    } else if (noTimestamps) {
+      return "No timestamp information available for the current queries and time range.";
+    } else if (noValues) {
+      return "No value information available for the current queries and time range.";
+    } return "";
+  }, [data]);
+
+  const setPeriod = ({ from, to }: {from: Date, to: Date}) => {
+    timeDispatch({ type: "SET_PERIOD", payload: { from, to } });
+  };
+
+  return (
+    <section
+      className={classNames({
+        "vm-explore-logs-chart": true,
+        "vm-block": true,
+        "vm-block_mobile": isMobile,
+      })}
+    >
+      {!error && loaded && noDataMessage && (
+        <div className="vm-explore-logs-chart__empty">
+          <Alert variant="info">{noDataMessage}</Alert>
+        </div>
+      )}
+
+      {error && loaded && noDataMessage && (
+        <div className="vm-explore-logs-chart__empty">
+          <Alert variant="error">{error}</Alert>
+        </div>
+      )}
+
+      {data && (
+        <BarHitsChart
+          data={data}
+          period={period}
+          setPeriod={setPeriod}
+        />
+      )}
+    </section>
+  );
+};
+
+export default ExploreLogsBarChart;
diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBarChart/style.scss b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBarChart/style.scss
new file mode 100644
index 000000000..d1e839bea
--- /dev/null
+++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBarChart/style.scss
@@ -0,0 +1,21 @@
+@use "src/styles/variables" as *;
+
+.vm-explore-logs-chart {
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 200px;
+  padding: 0 0 0 $padding-small !important;
+  width: calc(100vw - ($padding-medium * 2));
+
+  @media (max-width: 768px) {
+    width: calc(100vw);
+  }
+
+  &__empty {
+    position: absolute;
+    transform: translateY(-25px);
+    z-index: 2;
+  }
+}
diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBody/ExploreLogsBody.tsx b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBody/ExploreLogsBody.tsx
index 3a66a3503..f213b9a02 100644
--- a/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBody/ExploreLogsBody.tsx
+++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogsBody/ExploreLogsBody.tsx
@@ -48,7 +48,7 @@ const ExploreLogsBody: FC<ExploreLogBodyProps> = ({ data, loaded, markdownParsin
     ...item,
     _vmui_time: item._time ? dayjs(item._time).tz().format(`${DATE_TIME_FORMAT}.SSS`) : "",
     _vmui_data: JSON.stringify(item, null, 2),
-    _vmui_markdown: marked(item._msg.replace(/```/g, "\n```\n")) as string,
+    _vmui_markdown: item._msg ? marked(item._msg.replace(/```/g, "\n```\n")) as string : ""
   })) as Logs[], [data, timezone]);
 
   const columns = useMemo(() => {
diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/GroupLogs.tsx b/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/GroupLogs.tsx
index 82983a14f..ec0d21da2 100644
--- a/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/GroupLogs.tsx
+++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/GroupLogs.tsx
@@ -7,6 +7,8 @@ import { groupByMultipleKeys } from "../../../utils/array";
 import Tooltip from "../../../components/Main/Tooltip/Tooltip";
 import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
 import GroupLogsItem from "./GroupLogsItem";
+import { useAppState } from "../../../state/common/StateContext";
+import classNames from "classnames";
 
 interface TableLogsProps {
   logs: Logs[];
@@ -15,6 +17,7 @@ interface TableLogsProps {
 }
 
 const GroupLogs: FC<TableLogsProps> = ({ logs, markdownParsing }) => {
+  const { isDarkTheme } = useAppState();
   const copyToClipboard = useCopyToClipboard();
 
   const [copied, setCopied] = useState<string | null>(null);
@@ -32,7 +35,7 @@ const GroupLogs: FC<TableLogsProps> = ({ logs, markdownParsing }) => {
 
   const handleClickByPair = (pair: string) => async (e: MouseEvent<HTMLDivElement>) => {
     e.stopPropagation();
-    const isCopied = await copyToClipboard(`${pair}`);
+    const isCopied = await copyToClipboard(`${pair.replace(/=/, ": ")}`);
     if (isCopied) {
       setCopied(pair);
     }
@@ -63,7 +66,10 @@ const GroupLogs: FC<TableLogsProps> = ({ logs, markdownParsing }) => {
                     placement={"top-center"}
                   >
                     <div
-                      className="vm-group-logs-section-keys__pair"
+                      className={classNames({
+                        "vm-group-logs-section-keys__pair": true,
+                        "vm-group-logs-section-keys__pair_dark": isDarkTheme
+                      })}
                       onClick={handleClickByPair(pair)}
                     >
                       {pair}
diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/GroupLogsItem.tsx b/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/GroupLogsItem.tsx
index 64539db0e..0cfa42bbc 100644
--- a/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/GroupLogsItem.tsx
+++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/GroupLogsItem.tsx
@@ -1,10 +1,10 @@
-import React, { FC, useEffect, useState } from "preact/compat";
+import React, { FC, useEffect, useMemo, useState } from "preact/compat";
 import { Logs } from "../../../api/types";
 import "./style.scss";
 import useBoolean from "../../../hooks/useBoolean";
 import Button from "../../../components/Main/Button/Button";
 import Tooltip from "../../../components/Main/Tooltip/Tooltip";
-import { ArrowDropDownIcon, CopyIcon } from "../../../components/Main/Icons";
+import { ArrowDownIcon, CopyIcon } from "../../../components/Main/Icons";
 import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
 import classNames from "classnames";
 
@@ -23,6 +23,16 @@ const GroupLogsItem: FC<Props> = ({ log, markdownParsing }) => {
   const fields = Object.entries(log).filter(([key]) => !excludeKeys.includes(key));
   const hasFields = fields.length > 0;
 
+  const displayMessage = useMemo(() => {
+    if (log._msg) return log._msg;
+    if (!hasFields) return;
+    const dataObject = fields.reduce<{[key: string]: string}>((obj, [key, value]) => {
+      obj[key] = value;
+      return obj;
+    }, {});
+    return JSON.stringify(dataObject);
+  }, [log, fields, hasFields]);
+
   const copyToClipboard = useCopyToClipboard();
   const [copied, setCopied] = useState<number | null>(null);
 
@@ -56,7 +66,7 @@ const GroupLogsItem: FC<Props> = ({ log, markdownParsing }) => {
               "vm-group-logs-row-content__arrow_open": isOpenFields,
             })}
           >
-            <ArrowDropDownIcon/>
+            <ArrowDownIcon/>
           </div>
         )}
         <div
@@ -70,11 +80,12 @@ const GroupLogsItem: FC<Props> = ({ log, markdownParsing }) => {
         <div
           className={classNames({
             "vm-group-logs-row-content__msg": true,
-            "vm-group-logs-row-content__msg_missing": !log._msg
+            "vm-group-logs-row-content__msg_empty-msg": !log._msg,
+            "vm-group-logs-row-content__msg_missing": !displayMessage
           })}
           dangerouslySetInnerHTML={markdownParsing && log._vmui_markdown ? { __html: log._vmui_markdown } : undefined}
         >
-          {log._msg || "message missing"}
+          {displayMessage || "-"}
         </div>
       </div>
       {hasFields && isOpenFields && (
@@ -94,7 +105,7 @@ const GroupLogsItem: FC<Props> = ({ log, markdownParsing }) => {
                           color="gray"
                           size="small"
                           startIcon={<CopyIcon/>}
-                          onClick={createCopyHandler(`${key}: ${value}`, i)}
+                          onClick={createCopyHandler(`${key}: "${value}"`, i)}
                           ariaLabel="copy to clipboard"
                         />
                       </Tooltip>
diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/style.scss b/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/style.scss
index 71ee06179..7868be35b 100644
--- a/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/style.scss
+++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/GroupLogs/style.scss
@@ -21,7 +21,7 @@
         background-color: lighten($color-tropical-blue, 6%);
         color: darken($color-dodger-blue, 20%);
         border-radius: $border-radius-medium;
-        transition: background-color 0.3s ease-in, transform 0.1s ease-in;;
+        transition: background-color 0.3s ease-in, transform 0.1s ease-in, opacity 0.3s ease-in;
 
         &:hover {
           background-color: $color-tropical-blue;
@@ -30,6 +30,17 @@
         &:active {
           transform: translate(0, 3px);
         }
+
+        &_dark {
+          color: lighten($color-dodger-blue, 20%);
+          background-color: $color-background-body;
+          opacity: 0.8;
+
+          &:hover {
+            background-color: $color-background-body;
+            opacity: 1;
+          }
+        }
       }
     }
 
@@ -45,9 +56,8 @@
     &-content {
       position: relative;
       display: grid;
-      grid-template-columns: minmax(180px, max-content) 1fr;
-      gap: $padding-small;
-      padding: $padding-global;
+      grid-template-columns: auto minmax(180px, max-content) 1fr;
+      padding: $padding-global 0;
       cursor: pointer;
       transition: background-color 0.2s ease-in;
 
@@ -56,14 +66,11 @@
       }
 
       &__arrow {
-        position: absolute;
-        top: $padding-global;
-        left: 0;
         display: flex;
         align-items: center;
         justify-content: center;
-        width: 14px;
-        height: 14px;
+        width: 16px;
+        height: $font-size;
         transform: rotate(-90deg);
         transition: transform 0.2s ease-out;
 
@@ -76,6 +83,7 @@
         display: flex;
         align-items: flex-start;
         justify-content: flex-end;
+        margin-right: $padding-small;
         line-height: 1;
         white-space: nowrap;
 
@@ -91,6 +99,12 @@
         overflow-wrap: anywhere;
         line-height: 1.1;
 
+        &_empty-msg {
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+
         &_missing {
           color: $color-text-disabled;
           font-style: italic;
diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/hooks/useFetchLogHits.ts b/app/vmui/packages/vmui/src/pages/ExploreLogs/hooks/useFetchLogHits.ts
new file mode 100644
index 000000000..b6a709c7e
--- /dev/null
+++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/hooks/useFetchLogHits.ts
@@ -0,0 +1,76 @@
+import { useCallback, useMemo, useRef, useState } from "preact/compat";
+import { getLogHitsUrl } from "../../../api/logs";
+import { ErrorTypes } from "../../../types";
+import { LogHits } from "../../../api/types";
+import { useTimeState } from "../../../state/time/TimeStateContext";
+import dayjs from "dayjs";
+import { LOGS_BARS_VIEW } from "../../../constants/logs";
+
+export const useFetchLogHits = (server: string, query: string) => {
+  const { period } = useTimeState();
+  const [logHits, setLogHits] = useState<LogHits[]>([]);
+  const [isLoading, setIsLoading] = useState(false);
+  const [error, setError] = useState<ErrorTypes | string>();
+  const abortControllerRef = useRef(new AbortController());
+
+  const url = useMemo(() => getLogHitsUrl(server), [server]);
+
+  const options = useMemo(() => {
+    const start = dayjs(period.start * 1000);
+    const end = dayjs(period.end * 1000);
+    const totalSeconds = end.diff(start, "milliseconds");
+    const step = Math.ceil(totalSeconds / LOGS_BARS_VIEW) || 1;
+
+    return {
+      method: "POST",
+      body: new URLSearchParams({
+        query: query.trim(),
+        step: `${step}ms`,
+        start: start.toISOString(),
+        end: end.toISOString(),
+      })
+    };
+  }, [query, period]);
+
+  const fetchLogHits = useCallback(async () => {
+    abortControllerRef.current.abort();
+    abortControllerRef.current = new AbortController();
+    const { signal } = abortControllerRef.current;
+    setIsLoading(true);
+    setError(undefined);
+    try {
+      const response = await fetch(url, { ...options, signal });
+
+      if (!response.ok || !response.body) {
+        const text = await response.text();
+        setError(text);
+        setLogHits([]);
+        setIsLoading(false);
+        return;
+      }
+
+      const data = await response.json();
+      const hits = data?.hits as LogHits[];
+      if (!hits) {
+        setError("Error: No 'hits' field in response");
+        return;
+      }
+
+      setLogHits(hits);
+    } catch (e) {
+      if (e instanceof Error && e.name !== "AbortError") {
+        setError(String(e));
+        console.error(e);
+        setLogHits([]);
+      }
+    }
+    // setIsLoading(false);
+  }, [url, options]);
+
+  return {
+    logHits,
+    isLoading,
+    error,
+    fetchLogHits,
+  };
+};
diff --git a/app/vmui/packages/vmui/src/pages/ExploreLogs/hooks/useFetchLogs.ts b/app/vmui/packages/vmui/src/pages/ExploreLogs/hooks/useFetchLogs.ts
index 21f63449c..00801cc2f 100644
--- a/app/vmui/packages/vmui/src/pages/ExploreLogs/hooks/useFetchLogs.ts
+++ b/app/vmui/packages/vmui/src/pages/ExploreLogs/hooks/useFetchLogs.ts
@@ -1,4 +1,4 @@
-import { useCallback, useMemo, useState } from "preact/compat";
+import { useCallback, useMemo, useRef, useState } from "preact/compat";
 import { getLogsUrl } from "../../../api/logs";
 import { ErrorTypes } from "../../../types";
 import { Logs } from "../../../api/types";
@@ -10,6 +10,7 @@ export const useFetchLogs = (server: string, query: string, limit: number) => {
   const [logs, setLogs] = useState<Logs[]>([]);
   const [isLoading, setIsLoading] = useState(false);
   const [error, setError] = useState<ErrorTypes | string>();
+  const abortControllerRef = useRef(new AbortController());
 
   const url = useMemo(() => getLogsUrl(server), [server]);
 
@@ -35,11 +36,14 @@ export const useFetchLogs = (server: string, query: string, limit: number) => {
   };
 
   const fetchLogs = useCallback(async () => {
+    abortControllerRef.current.abort();
+    abortControllerRef.current = new AbortController();
+    const { signal } = abortControllerRef.current;
     const limit = Number(options.body.get("limit"));
     setIsLoading(true);
     setError(undefined);
     try {
-      const response = await fetch(url, options);
+      const response = await fetch(url, { ...options, signal });
       const text = await response.text();
 
       if (!response.ok || !response.body) {
@@ -53,10 +57,10 @@ export const useFetchLogs = (server: string, query: string, limit: number) => {
       const data = lines.map(parseLineToJSON).filter(line => line) as Logs[];
       setLogs(data);
     } catch (e) {
-      console.error(e);
-      setLogs([]);
-      if (e instanceof Error) {
-        setError(`${e.name}: ${e.message}`);
+      if (e instanceof Error && e.name !== "AbortError") {
+        setError(String(e));
+        console.error(e);
+        setLogs([]);
       }
     }
     setIsLoading(false);
diff --git a/app/vmui/packages/vmui/src/utils/uplot/axes.ts b/app/vmui/packages/vmui/src/utils/uplot/axes.ts
index c4c8f9b82..631ba133f 100644
--- a/app/vmui/packages/vmui/src/utils/uplot/axes.ts
+++ b/app/vmui/packages/vmui/src/utils/uplot/axes.ts
@@ -31,7 +31,7 @@ export const getAxes = (series: Series[], unit?: string): Axis[] => Array.from(n
     values: (u: uPlot, ticks: number[]) => formatTicks(u, ticks, unit)
   };
   if (!a) return { space: 80, values: timeValues, stroke, font };
-  if (!(Number(a) % 2)) return { ...axis, side: 1 };
+  if (!(Number(a) % 2) && a !== "y") return { ...axis, side: 1 };
   return axis;
 });
 
diff --git a/app/vmui/packages/vmui/src/utils/uplot/bars.ts b/app/vmui/packages/vmui/src/utils/uplot/bars.ts
new file mode 100644
index 000000000..be7fc1444
--- /dev/null
+++ b/app/vmui/packages/vmui/src/utils/uplot/bars.ts
@@ -0,0 +1,14 @@
+import uPlot from "uplot";
+import { LOGS_BARS_VIEW } from "../../constants/logs";
+
+export const barPaths = (
+  u: uPlot,
+  seriesIdx: number,
+  idx0: number,
+  idx1: number,
+): uPlot.Series.Paths | null => {
+  const barSize = (u.under.clientWidth/LOGS_BARS_VIEW ) - 1;
+  const barsPathBuilderFactory = uPlot?.paths?.bars?.({ size: [0.96, barSize] });
+  return barsPathBuilderFactory ? barsPathBuilderFactory(u, seriesIdx, idx0, idx1) : null;
+};
+
diff --git a/deployment/docker/docker-compose-victorialogs.yml b/deployment/docker/docker-compose-victorialogs.yml
index 2bfaf235a..9d75061a1 100644
--- a/deployment/docker/docker-compose-victorialogs.yml
+++ b/deployment/docker/docker-compose-victorialogs.yml
@@ -42,7 +42,7 @@ services:
 #  storing logs and serving read queries.
   victorialogs:
     container_name: victorialogs
-    image: docker.io/victoriametrics/victoria-logs:v0.20.0-victorialogs
+    image: docker.io/victoriametrics/victoria-logs:v0.20.2-victorialogs
     command:
       - "--storageDataPath=/vlogs"
       - "--httpListenAddr=:9428"
diff --git a/deployment/docker/victorialogs/filebeat-docker/docker-compose.yml b/deployment/docker/victorialogs/filebeat-docker/docker-compose.yml
index a51df33c7..e6fe2982f 100644
--- a/deployment/docker/victorialogs/filebeat-docker/docker-compose.yml
+++ b/deployment/docker/victorialogs/filebeat-docker/docker-compose.yml
@@ -22,7 +22,7 @@ services:
       - -beat.uri=http://filebeat-victorialogs:5066
 
   victorialogs:
-    image: docker.io/victoriametrics/victoria-logs:v0.20.0-victorialogs
+    image: docker.io/victoriametrics/victoria-logs:v0.20.2-victorialogs
     volumes:
       - victorialogs-filebeat-docker-vl:/vlogs
     ports:
diff --git a/deployment/docker/victorialogs/filebeat-syslog/docker-compose.yml b/deployment/docker/victorialogs/filebeat-syslog/docker-compose.yml
index c36103568..1ee1f9f48 100644
--- a/deployment/docker/victorialogs/filebeat-syslog/docker-compose.yml
+++ b/deployment/docker/victorialogs/filebeat-syslog/docker-compose.yml
@@ -13,7 +13,7 @@ services:
       - "5140:5140"
 
   victorialogs:
-    image: docker.io/victoriametrics/victoria-logs:v0.20.0-victorialogs
+    image: docker.io/victoriametrics/victoria-logs:v0.20.2-victorialogs
     volumes:
       - victorialogs-filebeat-syslog-vl:/vlogs
     ports:
diff --git a/deployment/docker/victorialogs/fluentbit-docker/docker-compose.yml b/deployment/docker/victorialogs/fluentbit-docker/docker-compose.yml
index 4675c78ca..b75b46e68 100644
--- a/deployment/docker/victorialogs/fluentbit-docker/docker-compose.yml
+++ b/deployment/docker/victorialogs/fluentbit-docker/docker-compose.yml
@@ -11,7 +11,7 @@ services:
       - "5140:5140"
 
   victorialogs:
-    image: docker.io/victoriametrics/victoria-logs:v0.20.0-victorialogs
+    image: docker.io/victoriametrics/victoria-logs:v0.20.2-victorialogs
     volumes:
       - victorialogs-fluentbit-vl:/vlogs
     ports:
diff --git a/deployment/docker/victorialogs/logstash/docker-compose.yml b/deployment/docker/victorialogs/logstash/docker-compose.yml
index 569b95960..3842adce3 100644
--- a/deployment/docker/victorialogs/logstash/docker-compose.yml
+++ b/deployment/docker/victorialogs/logstash/docker-compose.yml
@@ -14,7 +14,7 @@ services:
       - "5140:5140"
 
   victorialogs:
-    image: docker.io/victoriametrics/victoria-logs:v0.20.0-victorialogs
+    image: docker.io/victoriametrics/victoria-logs:v0.20.2-victorialogs
     volumes:
       - victorialogs-logstash-vl:/vlogs
     ports:
diff --git a/deployment/docker/victorialogs/promtail/docker-compose.yml b/deployment/docker/victorialogs/promtail/docker-compose.yml
index 48bc23daa..471f7c616 100644
--- a/deployment/docker/victorialogs/promtail/docker-compose.yml
+++ b/deployment/docker/victorialogs/promtail/docker-compose.yml
@@ -12,7 +12,7 @@ services:
       - "5140:5140"
 
   vlogs:
-    image: docker.io/victoriametrics/victoria-logs:v0.20.0-victorialogs
+    image: docker.io/victoriametrics/victoria-logs:v0.20.2-victorialogs
     volumes:
       - victorialogs-promtail-docker:/vlogs
     ports:
diff --git a/deployment/docker/victorialogs/vector-docker/docker-compose.yml b/deployment/docker/victorialogs/vector-docker/docker-compose.yml
index 82822545c..087c68cec 100644
--- a/deployment/docker/victorialogs/vector-docker/docker-compose.yml
+++ b/deployment/docker/victorialogs/vector-docker/docker-compose.yml
@@ -22,7 +22,7 @@ services:
         condition: service_healthy
 
   victorialogs:
-    image: docker.io/victoriametrics/victoria-logs:v0.20.0-victorialogs
+    image: docker.io/victoriametrics/victoria-logs:v0.20.2-victorialogs
     volumes:
       - victorialogs-vector-docker-vl:/vlogs
     ports:
diff --git a/deployment/logs-benchmark/docker-compose.yml b/deployment/logs-benchmark/docker-compose.yml
index 321c1a42a..3323672ff 100644
--- a/deployment/logs-benchmark/docker-compose.yml
+++ b/deployment/logs-benchmark/docker-compose.yml
@@ -3,7 +3,7 @@ version: '3'
 services:
   # Run `make package-victoria-logs` to build victoria-logs image
   vlogs:
-    image: docker.io/victoriametrics/victoria-logs:v0.20.0-victorialogs
+    image: docker.io/victoriametrics/victoria-logs:v0.20.2-victorialogs
     volumes:
       - vlogs:/vlogs
     ports:
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 564dd58a8..3a6fd1644 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -39,6 +39,7 @@ See also [LTS releases](https://docs.victoriametrics.com/lts-releases/).
 * FEATURE: [vmagent](https://docs.victoriametrics.com/vmagent/): increase default value of `-promscrape.maxDroppedTargets` command-line flag to 10_000 from 1000. This makes it easier to track down large number of dropped targets.
 * FEATURE: [vmsingle](https://docs.victoriametrics.com/single-server-victoriametrics/): check for ranged vector arguments in non-rollup expressions when `-search.disableImplicitConversion` or `-search.logImplicitConversion` are enabled. For example, `sum(up[5m])` or `absent(up[5m])` will fail to execute if these flags are set.
 * FEATURE: [vmsingle](https://docs.victoriametrics.com/single-server-victoriametrics/): validate that rollup expressions has ranged vector arguments passed when `-search.disableImplicitConversion` or `-search.logImplicitConversion` are enabled. For example, `rate(metric)` or `count_over_time(metric)` will fail to execute if these flags are set.
+* FEATURE: [vmalert-tool](https://docs.victoriametrics.com/vmalert-tool/): support file path with hierarchical patterns and regexpes, and http url in unittest cmd-line flag `-files`, e.g. `-files="http://<some-server-addr>/path/to/rules"` or `-files="dir/**/*.yaml"`.
 
 * BUGFIX: all VictoriaMetrics components: prioritize `-configAuthKey` and `-reloadAuthKey` over `-httpAuth.*` settings. This change aligns behavior of mentioned flags with other auth flags like `-metricsAuthKey`, `-flagsAuthKey`, `-pprofAuthKey`. Check [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6329).
 * BUGFIX: [vmctl](https://docs.victoriametrics.com/vmctl/): add `--disable-progress-bar` global command-line flag. It can be used for disabling dynamic progress bar for all migration modes. `--vm-disable-progress-bar`  command-line flag is deprecated and will be removed in the future releases. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6367).
@@ -50,6 +51,7 @@ See also [LTS releases](https://docs.victoriametrics.com/lts-releases/).
 * BUGFIX: [Single-node VictoriaMetrics](https://docs.victoriametrics.com/) and `vmstorage` in [VictoriaMetrics cluster](https://docs.victoriametrics.com/cluster-victoriametrics/): add validation for the max value specified for `-retentionPeriod`. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6330) for details.
 * BUGFIX: [vmselect](https://docs.victoriametrics.com/cluster-victoriametrics/): calculate the exact number of [raw samples](https://docs.victoriametrics.com/keyconcepts/#raw-samples) during query processing, the limit is specified via command-line flag `-search.maxSamplesPerQuery`. Previously, samples could have been over-counted and lead to false-positive errors of `maxSamplesPerQuery`limit exceeded. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5851).
 * BUGFIX: [vmui](https://docs.victoriametrics.com/#vmui): **copy row** button in Table view produces unexpected result. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6421) and [pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/6495).
+* BUGFIX: [vmalert-tool](https://docs.victoriametrics.com/vmalert-tool/): prevent hanging when processing groups without rules. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6500).
 
 ## [v1.102.0-rc1](https://github.com/VictoriaMetrics/VictoriaMetrics/releases/tag/v1.102.0-rc1)
 
diff --git a/docs/Cluster-VictoriaMetrics.md b/docs/Cluster-VictoriaMetrics.md
index 84b3c9289..fbbdfeaf7 100644
--- a/docs/Cluster-VictoriaMetrics.md
+++ b/docs/Cluster-VictoriaMetrics.md
@@ -1487,6 +1487,8 @@ Below is the output for `/path/to/vmselect -help`:
      Whether to deny partial responses if a part of -storageNode instances fail to perform queries; this trades availability over consistency; see also -search.maxQueryDuration
   -search.disableCache
      Whether to disable response caching. This may be useful when ingesting historical data. See https://docs.victoriametrics.com/#backfilling . See also -search.resetRollupResultCacheOnStartup
+  -search.disableImplicitConversion
+     Whether to return an error for queries that rely on implicit subquery conversions, see https://docs.victoriametrics.com/metricsql/#subqueries for details. See also -search.logImplicitConversion
   -search.graphiteMaxPointsPerSeries int
      The maximum number of points per series Graphite render API can return (default 1000000)
   -search.graphiteStorageStep duration
@@ -1495,6 +1497,8 @@ Below is the output for `/path/to/vmselect -help`:
      Whether to ignore match[], extra_filters[] and extra_label query args at /api/v1/labels and /api/v1/label/.../values . This may be useful for decreasing load on VictoriaMetrics when extra filters match too many time series. The downside is that superfluous labels or series could be returned, which do not match the extra filters. See also -search.maxLabelsAPISeries and -search.maxLabelsAPIDuration
   -search.latencyOffset duration
      The time when data points become visible in query results after the collection. It can be overridden on per-query basis via latency_offset arg. Too small value can result in incomplete last points for query results (default 30s)
+  -search.logImplicitConversion
+     Whether to log queries with implicit subquery conversions, see https://docs.victoriametrics.com/metricsql/#subqueries for details. Such conversion can be disabled using -search.disableImplicitConversion
   -search.logQueryMemoryUsage size
      Log query and increment vm_memory_intensive_queries_total metric each time the query requires more memory than specified by this flag. This may help detecting and optimizing heavy queries. Query logging is disabled by default. See also -search.logSlowQueryDuration and -search.maxMemoryPerQuery
      Supports the following optional suffixes for size values: KB, MB, GB, TB, KiB, MiB, GiB, TiB (default 0)
diff --git a/docs/README.md b/docs/README.md
index cc743edbd..1c14dbd15 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -3096,6 +3096,8 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
      Whether to disable automatic response cache reset if a sample with timestamp outside -search.cacheTimestampOffset is inserted into VictoriaMetrics
   -search.disableCache
      Whether to disable response caching. This may be useful when ingesting historical data. See https://docs.victoriametrics.com/#backfilling . See also -search.resetRollupResultCacheOnStartup
+  -search.disableImplicitConversion
+     Whether to return an error for queries that rely on implicit subquery conversions, see https://docs.victoriametrics.com/metricsql/#subqueries for details. See also -search.logImplicitConversion
   -search.graphiteMaxPointsPerSeries int
      The maximum number of points per series Graphite render API can return (default 1000000)
   -search.graphiteStorageStep duration
@@ -3104,6 +3106,8 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
      Whether to ignore match[], extra_filters[] and extra_label query args at /api/v1/labels and /api/v1/label/.../values . This may be useful for decreasing load on VictoriaMetrics when extra filters match too many time series. The downside is that superfluous labels or series could be returned, which do not match the extra filters. See also -search.maxLabelsAPISeries and -search.maxLabelsAPIDuration
   -search.latencyOffset duration
      The time when data points become visible in query results after the collection. It can be overridden on per-query basis via latency_offset arg. Too small value can result in incomplete last points for query results (default 30s)
+  -search.logImplicitConversion
+     Whether to log queries with implicit subquery conversions, see https://docs.victoriametrics.com/metricsql/#subqueries for details. Such conversion can be disabled using -search.disableImplicitConversion
   -search.logQueryMemoryUsage size
      Log query and increment vm_memory_intensive_queries_total metric each time the query requires more memory than specified by this flag. This may help detecting and optimizing heavy queries. Query logging is disabled by default. See also -search.logSlowQueryDuration and -search.maxMemoryPerQuery
      Supports the following optional suffixes for size values: KB, MB, GB, TB, KiB, MiB, GiB, TiB (default 0)
diff --git a/docs/Single-server-VictoriaMetrics.md b/docs/Single-server-VictoriaMetrics.md
index 0f6e198b8..2432465a3 100644
--- a/docs/Single-server-VictoriaMetrics.md
+++ b/docs/Single-server-VictoriaMetrics.md
@@ -3104,6 +3104,8 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
      Whether to disable automatic response cache reset if a sample with timestamp outside -search.cacheTimestampOffset is inserted into VictoriaMetrics
   -search.disableCache
      Whether to disable response caching. This may be useful when ingesting historical data. See https://docs.victoriametrics.com/#backfilling . See also -search.resetRollupResultCacheOnStartup
+  -search.disableImplicitConversion
+     Whether to return an error for queries that rely on implicit subquery conversions, see https://docs.victoriametrics.com/metricsql/#subqueries for details. See also -search.logImplicitConversion
   -search.graphiteMaxPointsPerSeries int
      The maximum number of points per series Graphite render API can return (default 1000000)
   -search.graphiteStorageStep duration
@@ -3112,6 +3114,8 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
      Whether to ignore match[], extra_filters[] and extra_label query args at /api/v1/labels and /api/v1/label/.../values . This may be useful for decreasing load on VictoriaMetrics when extra filters match too many time series. The downside is that superfluous labels or series could be returned, which do not match the extra filters. See also -search.maxLabelsAPISeries and -search.maxLabelsAPIDuration
   -search.latencyOffset duration
      The time when data points become visible in query results after the collection. It can be overridden on per-query basis via latency_offset arg. Too small value can result in incomplete last points for query results (default 30s)
+  -search.logImplicitConversion
+     Whether to log queries with implicit subquery conversions, see https://docs.victoriametrics.com/metricsql/#subqueries for details. Such conversion can be disabled using -search.disableImplicitConversion
   -search.logQueryMemoryUsage size
      Log query and increment vm_memory_intensive_queries_total metric each time the query requires more memory than specified by this flag. This may help detecting and optimizing heavy queries. Query logging is disabled by default. See also -search.logSlowQueryDuration and -search.maxMemoryPerQuery
      Supports the following optional suffixes for size values: KB, MB, GB, TB, KiB, MiB, GiB, TiB (default 0)
diff --git a/docs/VictoriaLogs/CHANGELOG.md b/docs/VictoriaLogs/CHANGELOG.md
index 9a9d39a93..d197c44f5 100644
--- a/docs/VictoriaLogs/CHANGELOG.md
+++ b/docs/VictoriaLogs/CHANGELOG.md
@@ -19,6 +19,22 @@ according to [these docs](https://docs.victoriametrics.com/victorialogs/quicksta
 
 ## tip
 
+* FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): add a bar chart displaying the number of log entries over a time range. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6404).
+
+## [v0.20.2](https://github.com/VictoriaMetrics/VictoriaMetrics/releases/tag/v0.20.2-victorialogs)
+
+Released at 2024-06-18
+
+* BUGFIX: properly parse timestamps with nanosecond precision for logs ingested via [jsonline format](https://docs.victoriametrics.com/victorialogs/data-ingestion/#json-stream-api). The bug has been introduced in [v0.20.0 release](https://github.com/VictoriaMetrics/VictoriaMetrics/releases/tag/v0.20.0-victorialogs).
+
+## [v0.20.1](https://github.com/VictoriaMetrics/VictoriaMetrics/releases/tag/v0.20.1-victorialogs)
+
+Released at 2024-06-18
+
+* FEATURE: allow configuring multiple receivers with distinct configs for syslog messages. See [these docs](https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/#multiple-configs).
+
+* BUGFIX: properly read syslog messages over TCP and TLS connections according to [RFC5425](https://datatracker.ietf.org/doc/html/rfc5425) when [data ingestion for syslog protocol](https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/) is enabled.
+
 ## [v0.20.0](https://github.com/VictoriaMetrics/VictoriaMetrics/releases/tag/v0.20.0-victorialogs)
 
 Released at 2024-06-17
diff --git a/docs/VictoriaLogs/QuickStart.md b/docs/VictoriaLogs/QuickStart.md
index 7aa83fc7c..7cc97cb87 100644
--- a/docs/VictoriaLogs/QuickStart.md
+++ b/docs/VictoriaLogs/QuickStart.md
@@ -36,8 +36,8 @@ Just download archive for the needed Operating system and architecture, unpack i
 For example, the following commands download VictoriaLogs archive for Linux/amd64, unpack and run it:
 
 ```sh
-curl -L -O https://github.com/VictoriaMetrics/VictoriaMetrics/releases/download/v0.20.0-victorialogs/victoria-logs-linux-amd64-v0.20.0-victorialogs.tar.gz
-tar xzf victoria-logs-linux-amd64-v0.20.0-victorialogs.tar.gz
+curl -L -O https://github.com/VictoriaMetrics/VictoriaMetrics/releases/download/v0.20.2-victorialogs/victoria-logs-linux-amd64-v0.20.2-victorialogs.tar.gz
+tar xzf victoria-logs-linux-amd64-v0.20.2-victorialogs.tar.gz
 ./victoria-logs-prod
 ```
 
@@ -61,7 +61,7 @@ Here is the command to run VictoriaLogs in a Docker container:
 
 ```sh
 docker run --rm -it -p 9428:9428 -v ./victoria-logs-data:/victoria-logs-data \
-  docker.io/victoriametrics/victoria-logs:v0.20.0-victorialogs
+  docker.io/victoriametrics/victoria-logs:v0.20.2-victorialogs
 ```
 
 See also:
diff --git a/docs/VictoriaLogs/README.md b/docs/VictoriaLogs/README.md
index 4737c30a6..44f2d6c9b 100644
--- a/docs/VictoriaLogs/README.md
+++ b/docs/VictoriaLogs/README.md
@@ -140,34 +140,38 @@ via [VictoriaMetrics community channels](https://docs.victoriametrics.com/#commu
 Pass `-help` to VictoriaLogs in order to see the list of supported command-line flags with their description:
 
 ```
+  -blockcache.missesBeforeCaching int
+    	The number of cache misses before putting the block into cache. Higher values may reduce indexdb/dataBlocks cache size at the cost of higher CPU and disk read usage (default 2)
   -cacheExpireDuration duration
     	Items are removed from in-memory caches after they aren't accessed for this duration. Lower values may reduce memory usage at the cost of higher CPU usage. See also -prevCacheRemovalPercent (default 30m0s)
+  -elasticsearch.version string
+    	Elasticsearch version to report to client (default "8.9.0")
   -enableTCP6
     	Whether to enable IPv6 for listening and dialing. By default, only IPv4 TCP and UDP are used
   -envflag.enable
     	Whether to enable reading flags from environment variables in addition to the command line. Command line flag values have priority over values from environment vars. Flags are read only from the command line if this flag isn't set. See https://docs.victoriametrics.com/#environment-variables for more details
   -envflag.prefix string
     	Prefix for environment variables if -envflag.enable is set
+  -filestream.disableFadvise
+    	Whether to disable fadvise() syscall when reading large data files. The fadvise() syscall prevents from eviction of recently accessed data from OS page cache during background merges and backups. In some rare cases it is better to disable the syscall if it uses too much CPU
   -flagsAuthKey value
     	Auth key for /flags endpoint. It must be passed via authKey query arg. It overrides httpAuth.* settings
-        Flag value can be read from the given file when using -flagsAuthKey=file:///abs/path/to/file or -flagsAuthKey=file://./relative/path/to/file . Flag value can be read from the given http/https url when using -flagsAuthKey=http://host/path or -flagsAuthKey=https://host/path
+    	Flag value can be read from the given file when using -flagsAuthKey=file:///abs/path/to/file or -flagsAuthKey=file://./relative/path/to/file . Flag value can be read from the given http/https url when using -flagsAuthKey=http://host/path or -flagsAuthKey=https://host/path
   -fs.disableMmap
     	Whether to use pread() instead of mmap() for reading data files. By default, mmap() is used for 64-bit arches and pread() is used for 32-bit arches, since they cannot read data files bigger than 2^32 bytes in memory. mmap() is usually faster for reading small data chunks than pread()
   -futureRetention value
     	Log entries with timestamps bigger than now+futureRetention are rejected during data ingestion; see https://docs.victoriametrics.com/victorialogs/#retention
-    	The following optional suffixes are supported: h (hour), d (day), w (week), y (year). If suffix isn't set, then the duration is counted in months (default 2d)
-  -gogc int
-    	GOGC to use. See https://tip.golang.org/doc/gc-guide (default 100)
+    	The following optional suffixes are supported: s (second), m (minute), h (hour), d (day), w (week), y (year). If suffix isn't set, then the duration is counted in months (default 2d)
   -http.connTimeout duration
-        Incoming connections to -httpListenAddr are closed after the configured timeout. This may help evenly spreading load among a cluster of services behind TCP-level load balancer. Zero value disables closing of incoming connections (default 2m0s)
+    	Incoming connections to -httpListenAddr are closed after the configured timeout. This may help evenly spreading load among a cluster of services behind TCP-level load balancer. Zero value disables closing of incoming connections (default 2m0s)
   -http.disableResponseCompression
     	Disable compression of HTTP responses to save CPU resources. By default, compression is enabled to save network bandwidth
   -http.header.csp string
-     Value for 'Content-Security-Policy' header
+    	Value for 'Content-Security-Policy' header, recommended: "default-src 'self'"
   -http.header.frameOptions string
-     Value for 'X-Frame-Options' header
+    	Value for 'X-Frame-Options' header
   -http.header.hsts string
-     Value for 'Strict-Transport-Security' header
+    	Value for 'Strict-Transport-Security' header, recommended: 'max-age=31536000; includeSubDomains'
   -http.idleConnTimeout duration
     	Timeout for incoming idle http connections (default 1m0s)
   -http.maxGracefulShutdownDuration duration
@@ -178,13 +182,17 @@ Pass `-help` to VictoriaLogs in order to see the list of supported command-line
     	Optional delay before http server shutdown. During this delay, the server returns non-OK responses from /health page, so load balancers can route new requests to other servers
   -httpAuth.password value
     	Password for HTTP server's Basic Auth. The authentication is disabled if -httpAuth.username is empty
-        Flag value can be read from the given file when using -httpAuth.password=file:///abs/path/to/file or -httpAuth.password=file://./relative/path/to/file . Flag value can be read from the given http/https url when using -httpAuth.password=http://host/path or -httpAuth.password=https://host/path
+    	Flag value can be read from the given file when using -httpAuth.password=file:///abs/path/to/file or -httpAuth.password=file://./relative/path/to/file . Flag value can be read from the given http/https url when using -httpAuth.password=http://host/path or -httpAuth.password=https://host/path
   -httpAuth.username string
     	Username for HTTP server's Basic Auth. The authentication is disabled if empty. See also -httpAuth.password
-  -httpListenAddr string
-    	TCP address to listen for http connections. See also -httpListenAddr.useProxyProtocol (default ":9428")
-  -httpListenAddr.useProxyProtocol
-    	Whether to use proxy protocol for connections accepted at -httpListenAddr . See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt . With enabled proxy protocol http server cannot serve regular /metrics endpoint. Use -pushmetrics.url for metrics pushing
+  -httpListenAddr array
+    	TCP address to listen for incoming http requests. See also -httpListenAddr.useProxyProtocol
+    	Supports an array of values separated by comma or specified via multiple flags.
+    	Value can contain comma inside single-quoted or double-quoted string, {}, [] and () braces.
+  -httpListenAddr.useProxyProtocol array
+    	Whether to use proxy protocol for connections accepted at the given -httpListenAddr . See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt . With enabled proxy protocol http server cannot serve regular /metrics endpoint. Use -pushmetrics.url for metrics pushing
+    	Supports array of values separated by comma or specified via multiple flags.
+    	Empty values are set to false.
   -inmemoryDataFlushInterval duration
     	The interval for guaranteed saving of in-memory data to disk. The saved data survives unclean shutdowns such as OOM crash, hardware reset, SIGKILL, etc. Bigger intervals may help increase the lifetime of flash storage with limited write cycles (e.g. Raspberry PI). Smaller intervals increase disk IO load. Minimum supported value is 1s (default 5s)
   -insert.maxFieldsPerLine int
@@ -214,6 +222,8 @@ Pass `-help` to VictoriaLogs in order to see the list of supported command-line
     	Allows renaming fields in JSON formatted logs. Example: "ts:timestamp,msg:message" renames "ts" to "timestamp" and "msg" to "message". Supported fields: ts, level, caller, msg
   -loggerLevel string
     	Minimum level of errors to log. Possible values: INFO, WARN, ERROR, FATAL, PANIC (default "INFO")
+  -loggerMaxArgLen int
+    	The maximum length of a single logged argument. Longer arguments are replaced with 'arg_start..arg_end', where 'arg_start' and 'arg_end' is prefix and suffix of the arg with the length not exceeding -loggerMaxArgLen / 2 (default 1000)
   -loggerOutput string
     	Output for the logs. Supported values: stderr, stdout (default "stderr")
   -loggerTimezone string
@@ -221,52 +231,116 @@ Pass `-help` to VictoriaLogs in order to see the list of supported command-line
   -loggerWarnsPerSecondLimit int
     	Per-second limit on the number of WARN messages. If more than the given number of warns are emitted per second, then the remaining warns are suppressed. Zero values disable the rate limit
   -maxConcurrentInserts int
-    	The maximum number of concurrent insert requests. The default value should work for most cases, since it minimizes memory usage. The default value can be increased when clients send data over slow networks. See also -insert.maxQueueDuration.
+    	The maximum number of concurrent insert requests. Set higher value when clients send data over slow networks. Default value depends on the number of available CPU cores. It should work fine in most cases since it minimizes resource usage. See also -insert.maxQueueDuration (default 32)
   -memory.allowedBytes size
     	Allowed size of system memory VictoriaMetrics caches may occupy. This option overrides -memory.allowedPercent if set to a non-zero value. Too low a value may increase the cache miss rate usually resulting in higher CPU and disk IO usage. Too high a value may evict too much data from the OS page cache resulting in higher disk IO usage
     	Supports the following optional suffixes for size values: KB, MB, GB, TB, KiB, MiB, GiB, TiB (default 0)
   -memory.allowedPercent float
     	Allowed percent of system memory VictoriaMetrics caches may occupy. See also -memory.allowedBytes. Too low a value may increase cache miss rate usually resulting in higher CPU and disk IO usage. Too high a value may evict too much data from the OS page cache which will result in higher disk IO usage (default 60)
+  -metrics.exposeMetadata
+    	Whether to expose TYPE and HELP metadata at the /metrics page, which is exposed at -httpListenAddr . The metadata may be needed when the /metrics page is consumed by systems, which require this information. For example, Managed Prometheus in Google Cloud - https://cloud.google.com/stackdriver/docs/managed-prometheus/troubleshooting#missing-metric-type
   -metricsAuthKey value
     	Auth key for /metrics endpoint. It must be passed via authKey query arg. It overrides httpAuth.* settings
-        Flag value can be read from the given file when using -metricsAuthKey=file:///abs/path/to/file or -metricsAuthKey=file://./relative/path/to/file . Flag value can be read from the given http/https url when using -metricsAuthKey=http://host/path or -metricsAuthKey=https://host/path
+    	Flag value can be read from the given file when using -metricsAuthKey=file:///abs/path/to/file or -metricsAuthKey=file://./relative/path/to/file . Flag value can be read from the given http/https url when using -metricsAuthKey=http://host/path or -metricsAuthKey=https://host/path
   -pprofAuthKey value
     	Auth key for /debug/pprof/* endpoints. It must be passed via authKey query arg. It overrides httpAuth.* settings
-        Flag value can be read from the given file when using -pprofAuthKey=file:///abs/path/to/file or -pprofAuthKey=file://./relative/path/to/file . Flag value can be read from the given http/https url when using -pprofAuthKey=http://host/path or -pprofAuthKey=https://host/path
+    	Flag value can be read from the given file when using -pprofAuthKey=file:///abs/path/to/file or -pprofAuthKey=file://./relative/path/to/file . Flag value can be read from the given http/https url when using -pprofAuthKey=http://host/path or -pprofAuthKey=https://host/path
   -prevCacheRemovalPercent float
     	Items in the previous caches are removed when the percent of requests it serves becomes lower than this value. Higher values reduce memory usage at the cost of higher CPU usage. See also -cacheExpireDuration (default 0.1)
+  -pushmetrics.disableCompression
+    	Whether to disable request body compression when pushing metrics to every -pushmetrics.url
   -pushmetrics.extraLabel array
-    	Optional labels to add to metrics pushed to -pushmetrics.url . For example, -pushmetrics.extraLabel='instance="foo"' adds instance="foo" label to all the metrics pushed to -pushmetrics.url
+    	Optional labels to add to metrics pushed to every -pushmetrics.url . For example, -pushmetrics.extraLabel='instance="foo"' adds instance="foo" label to all the metrics pushed to every -pushmetrics.url
+    	Supports an array of values separated by comma or specified via multiple flags.
+    	Value can contain comma inside single-quoted or double-quoted string, {}, [] and () braces.
+  -pushmetrics.header array
+    	Optional HTTP request header to send to every -pushmetrics.url . For example, -pushmetrics.header='Authorization: Basic foobar' adds 'Authorization: Basic foobar' header to every request to every -pushmetrics.url
     	Supports an array of values separated by comma or specified via multiple flags.
+    	Value can contain comma inside single-quoted or double-quoted string, {}, [] and () braces.
   -pushmetrics.interval duration
-    	Interval for pushing metrics to -pushmetrics.url (default 10s)
+    	Interval for pushing metrics to every -pushmetrics.url (default 10s)
   -pushmetrics.url array
     	Optional URL to push metrics exposed at /metrics page. See https://docs.victoriametrics.com/#push-metrics . By default, metrics exposed at /metrics page aren't pushed to any remote storage
     	Supports an array of values separated by comma or specified via multiple flags.
+    	Value can contain comma inside single-quoted or double-quoted string, {}, [] and () braces.
   -retentionPeriod value
     	Log entries with timestamps older than now-retentionPeriod are automatically deleted; log entries with timestamps outside the retention are also rejected during data ingestion; the minimum supported retention is 1d (one day); see https://docs.victoriametrics.com/victorialogs/#retention
-    	The following optional suffixes are supported: h (hour), d (day), w (week), y (year). If suffix isn't set, then the duration is counted in months (default 7d)
+    	The following optional suffixes are supported: s (second), m (minute), h (hour), d (day), w (week), y (year). If suffix isn't set, then the duration is counted in months (default 7d)
   -search.maxConcurrentRequests int
-    	The maximum number of concurrent search requests. It shouldn't be high, since a single request can saturate all the CPU cores, while many concurrently executed requests may require high amounts of memory. See also -search.maxQueueDuration (default 6)
+    	The maximum number of concurrent search requests. It shouldn't be high, since a single request can saturate all the CPU cores, while many concurrently executed requests may require high amounts of memory. See also -search.maxQueueDuration (default 16)
   -search.maxQueryDuration duration
     	The maximum duration for query execution (default 30s)
   -search.maxQueueDuration duration
     	The maximum time the search request waits for execution when -search.maxConcurrentRequests limit is reached; see also -search.maxQueryDuration (default 10s)
-  -storageDataPath string
-    	Path to directory with the VictoriaLogs data; see https://docs.victoriametrics.com/victorialogs/#storage (default "victoria-logs-data")
   -storage.minFreeDiskSpaceBytes size
     	The minimum free disk space at -storageDataPath after which the storage stops accepting new data
     	Supports the following optional suffixes for size values: KB, MB, GB, TB, KiB, MiB, GiB, TiB (default 10000000)
-  -tls
-    	Whether to enable TLS for incoming HTTP requests at -httpListenAddr (aka https). -tlsCertFile and -tlsKeyFile must be set if -tls is set
-  -tlsCertFile string
-    	Path to file with TLS certificate if -tls is set. Prefer ECDSA certs instead of RSA certs as RSA certs are slower. The provided certificate file is automatically re-read every second, so it can be dynamically updated
+  -storageDataPath string
+    	Path to directory with the VictoriaLogs data; see https://docs.victoriametrics.com/victorialogs/#storage (default "victoria-logs-data")
+  -syslog.compressMethod.tcp array
+    	Compression method for syslog messages received at the corresponding -syslog.listenAddr.tcp. Supported values: none, gzip, deflate. See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/
+    	Supports an array of values separated by comma or specified via multiple flags.
+    	Value can contain comma inside single-quoted or double-quoted string, {}, [] and () braces.
+  -syslog.compressMethod.udp array
+    	Compression method for syslog messages received at the corresponding -syslog.listenAddr.udp. Supported values: none, gzip, deflate. See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/
+    	Supports an array of values separated by comma or specified via multiple flags.
+    	Value can contain comma inside single-quoted or double-quoted string, {}, [] and () braces.
+  -syslog.listenAddr.tcp array
+    	Comma-separated list of TCP addresses to listen to for Syslog messages. See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/
+    	Supports an array of values separated by comma or specified via multiple flags.
+    	Value can contain comma inside single-quoted or double-quoted string, {}, [] and () braces.
+  -syslog.listenAddr.udp array
+    	Comma-separated list of UDP address to listen to for Syslog messages. See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/
+    	Supports an array of values separated by comma or specified via multiple flags.
+    	Value can contain comma inside single-quoted or double-quoted string, {}, [] and () braces.
+  -syslog.tenantID.tcp array
+    	TenantID for logs ingested via the corresponding -syslog.listenAddr.tcp. See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/
+    	Supports an array of values separated by comma or specified via multiple flags.
+    	Value can contain comma inside single-quoted or double-quoted string, {}, [] and () braces.
+  -syslog.tenantID.udp array
+    	TenantID for logs ingested via the corresponding -syslog.listenAddr.udp. See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/
+    	Supports an array of values separated by comma or specified via multiple flags.
+    	Value can contain comma inside single-quoted or double-quoted string, {}, [] and () braces.
+  -syslog.timezone string
+    	Timezone to use when parsing timestamps in RFC3164 syslog messages. Timezone must be a valid IANA Time Zone. For example: America/New_York, Europe/Berlin, Etc/GMT+3 . See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/ (default "Local")
+  -syslog.tls array
+    	Whether to enable TLS for receiving syslog messages at the corresponding -syslog.listenAddr.tcp. The corresponding -syslog.tlsCertFile and -syslog.tlsKeyFile must be set if -syslog.tls is set. See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/
+    	Supports array of values separated by comma or specified via multiple flags.
+    	Empty values are set to false.
+  -syslog.tlsCertFile array
+    	Path to file with TLS certificate for the corresponding -syslog.listenAddr.tcp if the corresponding -syslog.tls is set. Prefer ECDSA certs instead of RSA certs as RSA certs are slower. The provided certificate file is automatically re-read every second, so it can be dynamically updated. See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/
+    	Supports an array of values separated by comma or specified via multiple flags.
+    	Value can contain comma inside single-quoted or double-quoted string, {}, [] and () braces.
+  -syslog.tlsCipherSuites array
+    	Optional list of TLS cipher suites for -syslog.listenAddr.tcp if -syslog.tls is set. See the list of supported cipher suites at https://pkg.go.dev/crypto/tls#pkg-constants . See also https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/
+    	Supports an array of values separated by comma or specified via multiple flags.
+    	Value can contain comma inside single-quoted or double-quoted string, {}, [] and () braces.
+  -syslog.tlsKeyFile array
+    	Path to file with TLS key for the corresponding -syslog.listenAddr.tcp if the corresponding -syslog.tls is set. The provided key file is automatically re-read every second, so it can be dynamically updated. See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/
+    	Supports an array of values separated by comma or specified via multiple flags.
+    	Value can contain comma inside single-quoted or double-quoted string, {}, [] and () braces.
+  -syslog.tlsMinVersion string
+    	The minimum TLS version to use for -syslog.listenAddr.tcp if -syslog.tls is set. Supported values: TLS10, TLS11, TLS12, TLS13. See https://docs.victoriametrics.com/victorialogs/data-ingestion/syslog/ (default "TLS13")
+  -tls array
+    	Whether to enable TLS for incoming HTTP requests at the given -httpListenAddr (aka https). -tlsCertFile and -tlsKeyFile must be set if -tls is set. See also -mtls
+    	Supports array of values separated by comma or specified via multiple flags.
+    	Empty values are set to false.
+  -tlsCertFile array
+    	Path to file with TLS certificate for the corresponding -httpListenAddr if -tls is set. Prefer ECDSA certs instead of RSA certs as RSA certs are slower. The provided certificate file is automatically re-read every second, so it can be dynamically updated. See also -tlsAutocertHosts
+    	Supports an array of values separated by comma or specified via multiple flags.
+    	Value can contain comma inside single-quoted or double-quoted string, {}, [] and () braces.
   -tlsCipherSuites array
     	Optional list of TLS cipher suites for incoming requests over HTTPS if -tls is set. See the list of supported cipher suites at https://pkg.go.dev/crypto/tls#pkg-constants
     	Supports an array of values separated by comma or specified via multiple flags.
-  -tlsKeyFile string
-    	Path to file with TLS key if -tls is set. The provided key file is automatically re-read every second, so it can be dynamically updated
-  -tlsMinVersion string
-    	Optional minimum TLS version to use for incoming requests over HTTPS if -tls is set. Supported values: TLS10, TLS11, TLS12, TLS13
+    	Value can contain comma inside single-quoted or double-quoted string, {}, [] and () braces.
+  -tlsKeyFile array
+    	Path to file with TLS key for the corresponding -httpListenAddr if -tls is set. The provided key file is automatically re-read every second, so it can be dynamically updated. See also -tlsAutocertHosts
+    	Supports an array of values separated by comma or specified via multiple flags.
+    	Value can contain comma inside single-quoted or double-quoted string, {}, [] and () braces.
+  -tlsMinVersion array
+    	Optional minimum TLS version to use for the corresponding -httpListenAddr if -tls is set. Supported values: TLS10, TLS11, TLS12, TLS13
+    	Supports an array of values separated by comma or specified via multiple flags.
+    	Value can contain comma inside single-quoted or double-quoted string, {}, [] and () braces.
   -version
+    	Show VictoriaMetrics version
 ```
diff --git a/docs/VictoriaLogs/data-ingestion/syslog.md b/docs/VictoriaLogs/data-ingestion/syslog.md
index c6137bb7a..86f4de3b0 100644
--- a/docs/VictoriaLogs/data-ingestion/syslog.md
+++ b/docs/VictoriaLogs/data-ingestion/syslog.md
@@ -31,6 +31,11 @@ The following command starts VictoriaLogs, which accepts logs in Syslog format a
 ./victoria-logs -syslog.listenAddr.tcp=:514 -syslog.listenAddr.udp=:514
 ```
 
+VictoriaLogs can accept logs from the following syslog collectors:
+
+- [Rsyslog](https://www.rsyslog.com/). See [these docs](#rsyslog).
+- [Syslog-ng](https://www.syslog-ng.com/). See [these docs](#syslog-ng).
+
 Multiple logs in Syslog format can be ingested via a single TCP connection or via a single UDP packet - just put every log on a separate line
 and delimit them with `\n` char.
 
@@ -75,8 +80,8 @@ starts VictoriaLogs, which accepts TLS-encrypted syslog messages at TCP port 514
 ## Compression
 
 By default VictoriaLogs accepts uncompressed log messages in Syslog format at `-syslog.listenAddr.tcp` and `-syslog.listenAddr.udp` addresses.
-It is possible configuring VictoriaLogs to accept compressed log messages via `-syslog.compressMethod` command-line flag. The following
-compression methods are supported:
+It is possible configuring VictoriaLogs to accept compressed log messages via `-syslog.compressMethod.tcp` and `-syslog.compressMethod.udp` command-line flags.
+The following compression methods are supported:
 
 - `none` - no compression
 - `gzip` - [gzip compression](https://en.wikipedia.org/wiki/Gzip)
@@ -85,15 +90,51 @@ compression methods are supported:
 For example, the following command starts VictoriaLogs, which accepts gzip-compressed syslog messages at TCP port 514:
 
 ```sh
-./victoria-logs -syslog.listenAddr.tcp=:514 -syslog.compressMethod=gzip
+./victoria-logs -syslog.listenAddr.tcp=:514 -syslog.compressMethod.tcp=gzip
 ```
 
 ## Multitenancy
 
 By default, the ingested logs are stored in the `(AccountID=0, ProjectID=0)` [tenant](https://docs.victoriametrics.com/victorialogs/#multitenancy).
-If you need storing logs in other tenant, then specify the needed tenant via `-syslog.tenantID` command-line flag.
+If you need storing logs in other tenant, then specify the needed tenant via `-syslog.tenantID.tcp` or `-syslog.tenantID.udp` command-line flags
+depending on whether TCP or UDP ports are listened for syslog messages.
 For example, the following command starts VictoriaLogs, which writes syslog messages received at TCP port 514, to `(AccountID=12, ProjectID=34)` tenant:
 
 ```sh
-./victoria-logs -syslog.listenAddr.tcp=:514 -syslog.tenantID=12:34
+./victoria-logs -syslog.listenAddr.tcp=:514 -syslog.tenantID.tcp=12:34
+```
+
+## Multiple configs
+
+VictoriaLogs can accept syslog messages via multiple TCP and UDP ports with individual configurations for [compression](#compression), [security](#security)
+and [multitenancy](#multitenancy). Specify multiple command-line flags for this. For example, the following command starts VictoriaLogs,
+which accepts gzip-compressed syslog messages via TCP port 514 at localhost interface and stores them to [tenant](https://docs.victoriametrics.com/victorialogs/#multitenancy) `123:0`,
+plus it accepts TLS-encrypted syslog messages via TCP port 6514 and stores them to [tenant](https://docs.victoriametrics.com/victorialogs/#multitenancy) `567:0`:
+
+```sh
+./victoria-logs \
+  -syslog.listenAddr.tcp=localhost:514 -syslog.tenantID.tcp=123:0 -syslog.compressMethod.tcp=gzip -syslog.tls=false -syslog.tlsKeyFile='' -syslog.tlsCertFile='' \
+  -syslog.listenAddr.tcp=:6514 -syslog.tenantID.tcp=567:0 -syslog.compressMethod.tcp=none -syslog.tls=true -syslog.tlsKeyFile=/path/to/tls/key -syslog.tlsCertFile=/path/to/tls/cert
 ```
+
+## Rsyslog
+
+1. Run VictoriaLogs with `-syslog.listenAddr.tcp=:29514` command-line flag.
+1. Put the following line to [rsyslog](https://www.rsyslog.com/) config (this config is usually located at `/etc/rsyslog.conf`):
+   ```
+   *.* @@victoria-logs-server:29514
+   ```
+   Where `victoria-logs-server` is the hostname where VictoriaLogs runs. See [these docs](https://www.rsyslog.com/sending-messages-to-a-remote-syslog-server/)
+   for more details.
+
+## Syslog-ng
+
+1. Run VictoriaLogs with `-syslog.listenAddr.tcp=:29514` command-line flag.
+1. Put the following line to [syslog-ng](https://www.syslog-ng.com/) config:
+   ```
+   destination d_remote {
+    tcp("victoria-logs-server" port(29514));
+   };
+   ```
+   Where `victoria-logs-server` is the hostname where VictoriaLogs runs.
+   See [these docs](https://www.syslog-ng.com/technical-documents/doc/syslog-ng-open-source-edition/3.19/administration-guide/29#TOPIC-1094570) for details.
diff --git a/docs/keyConcepts.md b/docs/keyConcepts.md
index ef8229b3c..c6bb3acd1 100644
--- a/docs/keyConcepts.md
+++ b/docs/keyConcepts.md
@@ -488,7 +488,7 @@ The API consists of two main handlers for serving [instant queries](#instant-que
 Instant query executes the `query` expression at the given `time`:
 
 ```
-GET | POST /api/v1/query?query=...&time=...&step=...
+GET | POST /api/v1/query?query=...&time=...&step=...&timeout=...
 ```
 
 Params:
@@ -497,10 +497,13 @@ Params:
 * `time` - optional, [timestamp](https://docs.victoriametrics.com/single-server-victoriametrics/#timestamp-formats)
   in second precision to evaluate the `query` at. If omitted, `time` is set to `now()` (current timestamp).
   The `time` param can be specified in [multiple allowed formats](https://docs.victoriametrics.com/#timestamp-formats).
-* `step` - optional, the maximum [interval](https://prometheus.io/docs/prometheus/latest/querying/basics/#time-durations)
-  for searching for raw samples in the past when executing the `query` (used when a sample is missing at the specified instant).
-  For example, the request `/api/v1/query?query=up&step=1m` will look for the last written raw sample for the metric `up`
-  in the interval between `now()` and `now()-1m`. If omitted, `step` is set to `5m` (5 minutes).
+* `step` - optional [interval](https://prometheus.io/docs/prometheus/latest/querying/basics/#time-durations)
+  for searching for raw samples in the past when executing the `query` (used when a sample is missing at the specified `time`).
+  For example, the request `/api/v1/query?query=up&step=1m` looks for the last written raw sample for the metric `up`
+  in the interval between `now()` and `now()-1m`. If omitted, `step` is set to `5m` (5 minutes) by default.
+* `timeout` - optional query timeout. For example, `timeout=5s`. Query is canceled when the timeout is reached.
+  By default the timeout is set to the value of `-search.maxQueryDuration` command-line flag passed to single-node VictoriaMetrics
+  or to `vmselect` component of VictoriaMetrics cluster.
 
 The result of Instant query is a list of [time series](https://docs.victoriametrics.com/keyconcepts/#time-series)
 matching the filter in `query` expression. Each returned series contains exactly one `(timestamp, value)` entry,
@@ -579,7 +582,7 @@ the following scenarios:
 Range query executes the `query` expression at the given [`start`...`end`] time range with the given `step`:
 
 ```
-GET | POST /api/v1/query_range?query=...&start=...&end=...&step=...
+GET | POST /api/v1/query_range?query=...&start=...&end=...&step=...&timeout=...
 ```
 
 Params:
@@ -593,6 +596,9 @@ Params:
   between data points, which must be returned from the range query.
   The `query` is executed at `start`, `start+step`, `start+2*step`, ..., `end` timestamps.
   If the `step` isn't set, then it default to `5m` (5 minutes).
+* `timeout` - optional query timeout. For example, `timeout=5s`. Query is canceled when the timeout is reached.
+  By default the timeout is set to the value of `-search.maxQueryDuration` command-line flag passed to single-node VictoriaMetrics
+  or to `vmselect` component in VictoriaMetrics cluster.
 
 The result of Range query is a list of [time series](https://docs.victoriametrics.com/keyconcepts/#time-series)
 matching the filter in `query` expression. Each returned series contains `(timestamp, value)` results for the `query` executed
diff --git a/docs/vmalert-tool.md b/docs/vmalert-tool.md
index 07551e61a..529084494 100644
--- a/docs/vmalert-tool.md
+++ b/docs/vmalert-tool.md
@@ -26,8 +26,9 @@ It will perform the following actions:
 See how to run vmalert-tool for unit test below:
 
 ```
-# Run vmalert-tool with one or multiple test files via --files cmd-line flag
-./vmalert-tool unittest --files test1.yaml --files test2.yaml
+# Run vmalert-tool with one or multiple test files via `--files` cmd-line flag
+# Supports file path with hierarchical patterns and regexpes, and http url.
+./vmalert-tool unittest --files /path/to/file --files http://<some-server-addr>/path/to/test.yaml
 ```
 
 vmalert-tool unittest is compatible with [Prometheus config format for tests](https://prometheus.io/docs/prometheus/latest/configuration/unit_testing_rules/#test-file-format)
diff --git a/lib/logstorage/tenant_id.go b/lib/logstorage/tenant_id.go
index 1d406b875..101f53bd7 100644
--- a/lib/logstorage/tenant_id.go
+++ b/lib/logstorage/tenant_id.go
@@ -79,12 +79,17 @@ func GetTenantIDFromRequest(r *http.Request) (TenantID, error) {
 	return tenantID, nil
 }
 
-// GetTenantIDFromString returns tenantID from s.
-// String is expected in the form of accountID:projectID
-func GetTenantIDFromString(s string) (TenantID, error) {
+// ParseTenantID returns tenantID from s.
+//
+// s is expected in the form of accountID:projectID. If s is empty, then zero tenantID is returned.
+func ParseTenantID(s string) (TenantID, error) {
 	var tenantID TenantID
-	colon := strings.Index(s, ":")
-	if colon < 0 {
+	if s == "" {
+		return tenantID, nil
+	}
+
+	n := strings.Index(s, ":")
+	if n < 0 {
 		account, err := getUint32FromString(s)
 		if err != nil {
 			return tenantID, fmt.Errorf("cannot parse accountID from %q: %w", s, err)
@@ -94,13 +99,13 @@ func GetTenantIDFromString(s string) (TenantID, error) {
 		return tenantID, nil
 	}
 
-	account, err := getUint32FromString(s[:colon])
+	account, err := getUint32FromString(s[:n])
 	if err != nil {
 		return tenantID, fmt.Errorf("cannot parse accountID part from %q: %w", s, err)
 	}
 	tenantID.AccountID = account
 
-	project, err := getUint32FromString(s[colon+1:])
+	project, err := getUint32FromString(s[n+1:])
 	if err != nil {
 		return tenantID, fmt.Errorf("cannot parse projectID part from %q: %w", s, err)
 	}


Test patch:
diff --git a/app/vlinsert/elasticsearch/elasticsearch_test.go b/app/vlinsert/elasticsearch/elasticsearch_test.go
index 8a15dc0e2..1874fdefc 100644
--- a/app/vlinsert/elasticsearch/elasticsearch_test.go
+++ b/app/vlinsert/elasticsearch/elasticsearch_test.go
@@ -4,22 +4,18 @@ import (
 	"bytes"
 	"compress/gzip"
 	"fmt"
-	"reflect"
 	"testing"
 
-	"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
+	"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutils"
 )
 
-func TestReadBulkRequestFailure(t *testing.T) {
+func TestReadBulkRequest_Failure(t *testing.T) {
 	f := func(data string) {
 		t.Helper()
 
-		processLogMessage := func(timestamp int64, fields []logstorage.Field) {
-			t.Fatalf("unexpected call to processLogMessage with timestamp=%d, fields=%s", timestamp, fields)
-		}
-
+		tlp := &insertutils.TestLogMessageProcessor{}
 		r := bytes.NewBufferString(data)
-		rows, err := readBulkRequest(r, false, "_time", "_msg", processLogMessage)
+		rows, err := readBulkRequest(r, false, "_time", "_msg", tlp)
 		if err == nil {
 			t.Fatalf("expecting non-empty error")
 		}
@@ -36,52 +32,38 @@ func TestReadBulkRequestFailure(t *testing.T) {
 foobar`)
 }
 
-func TestReadBulkRequestSuccess(t *testing.T) {
+func TestReadBulkRequest_Success(t *testing.T) {
 	f := func(data, timeField, msgField string, rowsExpected int, timestampsExpected []int64, resultExpected string) {
 		t.Helper()
 
-		var timestamps []int64
-		var result string
-		processLogMessage := func(timestamp int64, fields []logstorage.Field) {
-			timestamps = append(timestamps, timestamp)
-			result += string(logstorage.MarshalFieldsToJSON(nil, fields)) + "\n"
-		}
+		tlp := &insertutils.TestLogMessageProcessor{}
 
 		// Read the request without compression
 		r := bytes.NewBufferString(data)
-		rows, err := readBulkRequest(r, false, timeField, msgField, processLogMessage)
+		rows, err := readBulkRequest(r, false, timeField, msgField, tlp)
 		if err != nil {
 			t.Fatalf("unexpected error: %s", err)
 		}
 		if rows != rowsExpected {
 			t.Fatalf("unexpected rows read; got %d; want %d", rows, rowsExpected)
 		}
-
-		if !reflect.DeepEqual(timestamps, timestampsExpected) {
-			t.Fatalf("unexpected timestamps;\ngot\n%d\nwant\n%d", timestamps, timestampsExpected)
-		}
-		if result != resultExpected {
-			t.Fatalf("unexpected result;\ngot\n%s\nwant\n%s", result, resultExpected)
+		if err := tlp.Verify(rowsExpected, timestampsExpected, resultExpected); err != nil {
+			t.Fatal(err)
 		}
 
 		// Read the request with compression
-		timestamps = nil
-		result = ""
+		tlp = &insertutils.TestLogMessageProcessor{}
 		compressedData := compressData(data)
 		r = bytes.NewBufferString(compressedData)
-		rows, err = readBulkRequest(r, true, timeField, msgField, processLogMessage)
+		rows, err = readBulkRequest(r, true, timeField, msgField, tlp)
 		if err != nil {
 			t.Fatalf("unexpected error: %s", err)
 		}
 		if rows != rowsExpected {
 			t.Fatalf("unexpected rows read; got %d; want %d", rows, rowsExpected)
 		}
-
-		if !reflect.DeepEqual(timestamps, timestampsExpected) {
-			t.Fatalf("unexpected timestamps;\ngot\n%d\nwant\n%d", timestamps, timestampsExpected)
-		}
-		if result != resultExpected {
-			t.Fatalf("unexpected result;\ngot\n%s\nwant\n%s", result, resultExpected)
+		if err := tlp.Verify(rowsExpected, timestampsExpected, resultExpected); err != nil {
+			t.Fatalf("verification failure after compression: %s", err)
 		}
 	}
 
@@ -104,8 +86,7 @@ func TestReadBulkRequestSuccess(t *testing.T) {
 	timestampsExpected := []int64{1686026891735000000, 1686026892735000000, 1686026893735000000}
 	resultExpected := `{"@timestamp":"","log.offset":"71770","log.file.path":"/var/log/auth.log","_msg":"foobar"}
 {"@timestamp":"","_msg":"baz"}
-{"_msg":"xyz","@timestamp":"","x":"y"}
-`
+{"_msg":"xyz","@timestamp":"","x":"y"}`
 	f(data, timeField, msgField, rowsExpected, timestampsExpected, resultExpected)
 }
 
diff --git a/app/vlinsert/elasticsearch/elasticsearch_timing_test.go b/app/vlinsert/elasticsearch/elasticsearch_timing_test.go
index ef62f0e4f..a0b4e485b 100644
--- a/app/vlinsert/elasticsearch/elasticsearch_timing_test.go
+++ b/app/vlinsert/elasticsearch/elasticsearch_timing_test.go
@@ -5,8 +5,8 @@ import (
 	"fmt"
 	"testing"
 
+	"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutils"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/bytesutil"
-	"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
 )
 
 func BenchmarkReadBulkRequest(b *testing.B) {
@@ -33,7 +33,7 @@ func benchmarkReadBulkRequest(b *testing.B, isGzip bool) {
 
 	timeField := "@timestamp"
 	msgField := "message"
-	processLogMessage := func(_ int64, _ []logstorage.Field) {}
+	blp := &insertutils.BenchmarkLogMessageProcessor{}
 
 	b.ReportAllocs()
 	b.SetBytes(int64(len(data)))
@@ -41,7 +41,7 @@ func benchmarkReadBulkRequest(b *testing.B, isGzip bool) {
 		r := &bytes.Reader{}
 		for pb.Next() {
 			r.Reset(dataBytes)
-			_, err := readBulkRequest(r, isGzip, timeField, msgField, processLogMessage)
+			_, err := readBulkRequest(r, isGzip, timeField, msgField, blp)
 			if err != nil {
 				panic(fmt.Errorf("unexpected error: %w", err))
 			}
diff --git a/app/vlinsert/insertutils/testutils.go b/app/vlinsert/insertutils/testutils.go
new file mode 100644
index 000000000..229b2c5bb
--- /dev/null
+++ b/app/vlinsert/insertutils/testutils.go
@@ -0,0 +1,53 @@
+package insertutils
+
+import (
+	"fmt"
+	"reflect"
+	"strings"
+
+	"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
+)
+
+// TestLogMessageProcessor implements LogMessageProcessor for testing.
+type TestLogMessageProcessor struct {
+	timestamps []int64
+	rows       []string
+}
+
+// AddRow adds row with the given timestamp and fields to tlp
+func (tlp *TestLogMessageProcessor) AddRow(timestamp int64, fields []logstorage.Field) {
+	tlp.timestamps = append(tlp.timestamps, timestamp)
+	tlp.rows = append(tlp.rows, string(logstorage.MarshalFieldsToJSON(nil, fields)))
+}
+
+// MustClose closes tlp.
+func (tlp *TestLogMessageProcessor) MustClose() {
+}
+
+// Verify verifies the number of rows, timestamps and results after AddRow calls.
+func (tlp *TestLogMessageProcessor) Verify(rowsExpected int, timestampsExpected []int64, resultExpected string) error {
+	result := strings.Join(tlp.rows, "\n")
+	if len(tlp.rows) != rowsExpected {
+		return fmt.Errorf("unexpected rows read; got %d; want %d;\nrows read:\n%s\nrows wanted\n%s", len(tlp.rows), rowsExpected, result, resultExpected)
+	}
+
+	if !reflect.DeepEqual(tlp.timestamps, timestampsExpected) {
+		return fmt.Errorf("unexpected timestamps;\ngot\n%d\nwant\n%d", tlp.timestamps, timestampsExpected)
+	}
+	if result != resultExpected {
+		return fmt.Errorf("unexpected result;\ngot\n%s\nwant\n%s", result, resultExpected)
+	}
+
+	return nil
+}
+
+// BenchmarkLogMessageProcessor implements LogMessageProcessor for benchmarks.
+type BenchmarkLogMessageProcessor struct{}
+
+// AddRow implements LogMessageProcessor interface.
+func (blp *BenchmarkLogMessageProcessor) AddRow(_ int64, _ []logstorage.Field) {
+}
+
+// MustClose implements LogMessageProcessor interface.
+func (blp *BenchmarkLogMessageProcessor) MustClose() {
+}
diff --git a/app/vlinsert/jsonline/jsonline_test.go b/app/vlinsert/jsonline/jsonline_test.go
index dc8d554cf..153b4db19 100644
--- a/app/vlinsert/jsonline/jsonline_test.go
+++ b/app/vlinsert/jsonline/jsonline_test.go
@@ -1,47 +1,24 @@
 package jsonline
 
 import (
-	"bufio"
 	"bytes"
-	"reflect"
 	"testing"
 
-	"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
+	"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutils"
 )
 
-func TestReadLine_Success(t *testing.T) {
+func TestProcessStreamInternal_Success(t *testing.T) {
 	f := func(data, timeField, msgField string, rowsExpected int, timestampsExpected []int64, resultExpected string) {
 		t.Helper()
 
-		var timestamps []int64
-		var result string
-		processLogMessage := func(timestamp int64, fields []logstorage.Field) {
-			timestamps = append(timestamps, timestamp)
-			result += string(logstorage.MarshalFieldsToJSON(nil, fields)) + "\n"
-		}
-
+		tlp := &insertutils.TestLogMessageProcessor{}
 		r := bytes.NewBufferString(data)
-		sc := bufio.NewScanner(r)
-		rows := 0
-		for {
-			ok, err := readLine(sc, timeField, msgField, processLogMessage)
-			if err != nil {
-				t.Fatalf("unexpected error: %s", err)
-			}
-			if !ok {
-				break
-			}
-			rows++
-		}
-		if rows != rowsExpected {
-			t.Fatalf("unexpected rows read; got %d; want %d", rows, rowsExpected)
+		if err := processStreamInternal(r, timeField, msgField, tlp); err != nil {
+			t.Fatalf("unexpected error: %s", err)
 		}
 
-		if !reflect.DeepEqual(timestamps, timestampsExpected) {
-			t.Fatalf("unexpected timestamps;\ngot\n%d\nwant\n%d", timestamps, timestampsExpected)
-		}
-		if result != resultExpected {
-			t.Fatalf("unexpected result;\ngot\n%s\nwant\n%s", result, resultExpected)
+		if err := tlp.Verify(rowsExpected, timestampsExpected, resultExpected); err != nil {
+			t.Fatal(err)
 		}
 	}
 
@@ -55,7 +32,24 @@ func TestReadLine_Success(t *testing.T) {
 	timestampsExpected := []int64{1686026891735000000, 1686026892735000000, 1686026893735000000}
 	resultExpected := `{"@timestamp":"","log.offset":"71770","log.file.path":"/var/log/auth.log","_msg":"foobar"}
 {"@timestamp":"","_msg":"baz"}
-{"_msg":"xyz","@timestamp":"","x":"y"}
-`
+{"_msg":"xyz","@timestamp":"","x":"y"}`
 	f(data, timeField, msgField, rowsExpected, timestampsExpected, resultExpected)
 }
+
+func TestProcessStreamInternal_Failure(t *testing.T) {
+	f := func(data string) {
+		t.Helper()
+
+		tlp := &insertutils.TestLogMessageProcessor{}
+		r := bytes.NewBufferString(data)
+		if err := processStreamInternal(r, "time", "", tlp); err == nil {
+			t.Fatalf("expecting non-nil error")
+		}
+	}
+
+	// invalid json
+	f("foobar")
+
+	// invalid timestamp field
+	f(`{"time":"foobar"}`)
+}
diff --git a/app/vlinsert/loki/loki_json_test.go b/app/vlinsert/loki/loki_json_test.go
index 6eaa88d36..4ccb34cb9 100644
--- a/app/vlinsert/loki/loki_json_test.go
+++ b/app/vlinsert/loki/loki_json_test.go
@@ -1,19 +1,17 @@
 package loki
 
 import (
-	"fmt"
-	"strings"
 	"testing"
 
-	"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
+	"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutils"
 )
 
-func TestParseJSONRequestFailure(t *testing.T) {
+func TestParseJSONRequest_Failure(t *testing.T) {
 	f := func(s string) {
 		t.Helper()
-		n, err := parseJSONRequest([]byte(s), func(_ int64, _ []logstorage.Field) {
-			t.Fatalf("unexpected call to parseJSONRequest callback!")
-		})
+
+		tlp := &insertutils.TestLogMessageProcessor{}
+		n, err := parseJSONRequest([]byte(s), tlp)
 		if err == nil {
 			t.Fatalf("expecting non-nil error")
 		}
@@ -56,39 +54,30 @@ func TestParseJSONRequestFailure(t *testing.T) {
 	f(`{"streams":[{"values":[["123",1234]]}]}`)
 }
 
-func TestParseJSONRequestSuccess(t *testing.T) {
-	f := func(s string, resultExpected string) {
+func TestParseJSONRequest_Success(t *testing.T) {
+	f := func(s string, timestampsExpected []int64, resultExpected string) {
 		t.Helper()
-		var lines []string
-		n, err := parseJSONRequest([]byte(s), func(timestamp int64, fields []logstorage.Field) {
-			var a []string
-			for _, f := range fields {
-				a = append(a, f.String())
-			}
-			line := fmt.Sprintf("_time:%d %s", timestamp, strings.Join(a, " "))
-			lines = append(lines, line)
-		})
+
+		tlp := &insertutils.TestLogMessageProcessor{}
+
+		n, err := parseJSONRequest([]byte(s), tlp)
 		if err != nil {
 			t.Fatalf("unexpected error: %s", err)
 		}
-		if n != len(lines) {
-			t.Fatalf("unexpected number of lines parsed; got %d; want %d", n, len(lines))
-		}
-		result := strings.Join(lines, "\n")
-		if result != resultExpected {
-			t.Fatalf("unexpected result;\ngot\n%s\nwant\n%s", result, resultExpected)
+		if err := tlp.Verify(n, timestampsExpected, resultExpected); err != nil {
+			t.Fatal(err)
 		}
 	}
 
 	// Empty streams
-	f(`{"streams":[]}`, ``)
-	f(`{"streams":[{"values":[]}]}`, ``)
-	f(`{"streams":[{"stream":{},"values":[]}]}`, ``)
-	f(`{"streams":[{"stream":{"foo":"bar"},"values":[]}]}`, ``)
+	f(`{"streams":[]}`, nil, ``)
+	f(`{"streams":[{"values":[]}]}`, nil, ``)
+	f(`{"streams":[{"stream":{},"values":[]}]}`, nil, ``)
+	f(`{"streams":[{"stream":{"foo":"bar"},"values":[]}]}`, nil, ``)
 
 	// Empty stream labels
-	f(`{"streams":[{"values":[["1577836800000000001", "foo bar"]]}]}`, `_time:1577836800000000001 "_msg":"foo bar"`)
-	f(`{"streams":[{"stream":{},"values":[["1577836800000000001", "foo bar"]]}]}`, `_time:1577836800000000001 "_msg":"foo bar"`)
+	f(`{"streams":[{"values":[["1577836800000000001", "foo bar"]]}]}`, []int64{1577836800000000001}, `{"_msg":"foo bar"}`)
+	f(`{"streams":[{"stream":{},"values":[["1577836800000000001", "foo bar"]]}]}`, []int64{1577836800000000001}, `{"_msg":"foo bar"}`)
 
 	// Non-empty stream labels
 	f(`{"streams":[{"stream":{
@@ -98,9 +87,9 @@ func TestParseJSONRequestSuccess(t *testing.T) {
 	["1577836800000000001", "foo bar"],
 	["1477836900005000002", "abc"],
 	["147.78369e9", "foobar"]
-]}]}`, `_time:1577836800000000001 "label1":"value1" "label2":"value2" "_msg":"foo bar"
-_time:1477836900005000002 "label1":"value1" "label2":"value2" "_msg":"abc"
-_time:147783690000 "label1":"value1" "label2":"value2" "_msg":"foobar"`)
+]}]}`, []int64{1577836800000000001, 1477836900005000002, 147783690000}, `{"label1":"value1","label2":"value2","_msg":"foo bar"}
+{"label1":"value1","label2":"value2","_msg":"abc"}
+{"label1":"value1","label2":"value2","_msg":"foobar"}`)
 
 	// Multiple streams
 	f(`{
@@ -124,7 +113,7 @@ _time:147783690000 "label1":"value1" "label2":"value2" "_msg":"foobar"`)
 			]
 		}
 	]
-}`, `_time:1577836800000000001 "foo":"bar" "a":"b" "_msg":"foo bar"
-_time:1577836900005000002 "foo":"bar" "a":"b" "_msg":"abc"
-_time:1877836900005000002 "x":"y" "_msg":"yx"`)
+}`, []int64{1577836800000000001, 1577836900005000002, 1877836900005000002}, `{"foo":"bar","a":"b","_msg":"foo bar"}
+{"foo":"bar","a":"b","_msg":"abc"}
+{"x":"y","_msg":"yx"}`)
 }
diff --git a/app/vlinsert/loki/loki_json_timing_test.go b/app/vlinsert/loki/loki_json_timing_test.go
index 985782c8b..d92006749 100644
--- a/app/vlinsert/loki/loki_json_timing_test.go
+++ b/app/vlinsert/loki/loki_json_timing_test.go
@@ -6,7 +6,7 @@ import (
 	"testing"
 	"time"
 
-	"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
+	"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutils"
 )
 
 func BenchmarkParseJSONRequest(b *testing.B) {
@@ -22,12 +22,13 @@ func BenchmarkParseJSONRequest(b *testing.B) {
 }
 
 func benchmarkParseJSONRequest(b *testing.B, streams, rows, labels int) {
+	blp := &insertutils.BenchmarkLogMessageProcessor{}
 	b.ReportAllocs()
 	b.SetBytes(int64(streams * rows))
 	b.RunParallel(func(pb *testing.PB) {
 		data := getJSONBody(streams, rows, labels)
 		for pb.Next() {
-			_, err := parseJSONRequest(data, func(_ int64, _ []logstorage.Field) {})
+			_, err := parseJSONRequest(data, blp)
 			if err != nil {
 				panic(fmt.Errorf("unexpected error: %w", err))
 			}
diff --git a/app/vlinsert/loki/loki_protobuf_test.go b/app/vlinsert/loki/loki_protobuf_test.go
index f6eb5f0ec..0e6424a72 100644
--- a/app/vlinsert/loki/loki_protobuf_test.go
+++ b/app/vlinsert/loki/loki_protobuf_test.go
@@ -6,83 +6,83 @@ import (
 	"testing"
 	"time"
 
+	"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutils"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
 	"github.com/golang/snappy"
 )
 
-func TestParseProtobufRequestSuccess(t *testing.T) {
-	f := func(s string, resultExpected string) {
+type testLogMessageProcessor struct {
+	pr PushRequest
+}
+
+func (tlp *testLogMessageProcessor) AddRow(timestamp int64, fields []logstorage.Field) {
+	msg := ""
+	for _, f := range fields {
+		if f.Name == "_msg" {
+			msg = f.Value
+		}
+	}
+	var a []string
+	for _, f := range fields {
+		if f.Name == "_msg" {
+			continue
+		}
+		item := fmt.Sprintf("%s=%q", f.Name, f.Value)
+		a = append(a, item)
+	}
+	labels := "{" + strings.Join(a, ", ") + "}"
+	tlp.pr.Streams = append(tlp.pr.Streams, Stream{
+		Labels: labels,
+		Entries: []Entry{
+			{
+				Timestamp: time.Unix(0, timestamp),
+				Line:      msg,
+			},
+		},
+	})
+}
+
+func (tlp *testLogMessageProcessor) MustClose() {
+}
+
+func TestParseProtobufRequest_Success(t *testing.T) {
+	f := func(s string, timestampsExpected []int64, resultExpected string) {
 		t.Helper()
-		var pr PushRequest
-		n, err := parseJSONRequest([]byte(s), func(timestamp int64, fields []logstorage.Field) {
-			msg := ""
-			for _, f := range fields {
-				if f.Name == "_msg" {
-					msg = f.Value
-				}
-			}
-			var a []string
-			for _, f := range fields {
-				if f.Name == "_msg" {
-					continue
-				}
-				item := fmt.Sprintf("%s=%q", f.Name, f.Value)
-				a = append(a, item)
-			}
-			labels := "{" + strings.Join(a, ", ") + "}"
-			pr.Streams = append(pr.Streams, Stream{
-				Labels: labels,
-				Entries: []Entry{
-					{
-						Timestamp: time.Unix(0, timestamp),
-						Line:      msg,
-					},
-				},
-			})
-		})
+
+		tlp := &testLogMessageProcessor{}
+		n, err := parseJSONRequest([]byte(s), tlp)
 		if err != nil {
 			t.Fatalf("unexpected error: %s", err)
 		}
-		if n != len(pr.Streams) {
-			t.Fatalf("unexpected number of streams; got %d; want %d", len(pr.Streams), n)
+		if n != len(tlp.pr.Streams) {
+			t.Fatalf("unexpected number of streams; got %d; want %d", len(tlp.pr.Streams), n)
 		}
 
-		data, err := pr.Marshal()
+		data, err := tlp.pr.Marshal()
 		if err != nil {
 			t.Fatalf("unexpected error when marshaling PushRequest: %s", err)
 		}
 		encodedData := snappy.Encode(nil, data)
 
-		var lines []string
-		n, err = parseProtobufRequest(encodedData, func(timestamp int64, fields []logstorage.Field) {
-			var a []string
-			for _, f := range fields {
-				a = append(a, f.String())
-			}
-			line := fmt.Sprintf("_time:%d %s", timestamp, strings.Join(a, " "))
-			lines = append(lines, line)
-		})
+		tlp2 := &insertutils.TestLogMessageProcessor{}
+		n, err = parseProtobufRequest(encodedData, tlp2)
 		if err != nil {
 			t.Fatalf("unexpected error: %s", err)
 		}
-		if n != len(lines) {
-			t.Fatalf("unexpected number of lines parsed; got %d; want %d", n, len(lines))
-		}
-		result := strings.Join(lines, "\n")
-		if result != resultExpected {
-			t.Fatalf("unexpected result;\ngot\n%s\nwant\n%s", result, resultExpected)
+		if err := tlp2.Verify(n, timestampsExpected, resultExpected); err != nil {
+			t.Fatal(err)
 		}
 	}
 
 	// Empty streams
-	f(`{"streams":[]}`, ``)
-	f(`{"streams":[{"values":[]}]}`, ``)
-	f(`{"streams":[{"stream":{},"values":[]}]}`, ``)
-	f(`{"streams":[{"stream":{"foo":"bar"},"values":[]}]}`, ``)
+	f(`{"streams":[]}`, nil, ``)
+	f(`{"streams":[{"values":[]}]}`, nil, ``)
+	f(`{"streams":[{"stream":{},"values":[]}]}`, nil, ``)
+	f(`{"streams":[{"stream":{"foo":"bar"},"values":[]}]}`, nil, ``)
 
 	// Empty stream labels
-	f(`{"streams":[{"values":[["1577836800000000001", "foo bar"]]}]}`, `_time:1577836800000000001 "_msg":"foo bar"`)
-	f(`{"streams":[{"stream":{},"values":[["1577836800000000001", "foo bar"]]}]}`, `_time:1577836800000000001 "_msg":"foo bar"`)
+	f(`{"streams":[{"values":[["1577836800000000001", "foo bar"]]}]}`, []int64{1577836800000000001}, `{"_msg":"foo bar"}`)
+	f(`{"streams":[{"stream":{},"values":[["1577836800000000001", "foo bar"]]}]}`, []int64{1577836800000000001}, `{"_msg":"foo bar"}`)
 
 	// Non-empty stream labels
 	f(`{"streams":[{"stream":{
@@ -92,9 +92,9 @@ func TestParseProtobufRequestSuccess(t *testing.T) {
 	["1577836800000000001", "foo bar"],
 	["1477836900005000002", "abc"],
 	["147.78369e9", "foobar"]
-]}]}`, `_time:1577836800000000001 "label1":"value1" "label2":"value2" "_msg":"foo bar"
-_time:1477836900005000002 "label1":"value1" "label2":"value2" "_msg":"abc"
-_time:147783690000 "label1":"value1" "label2":"value2" "_msg":"foobar"`)
+]}]}`, []int64{1577836800000000001, 1477836900005000002, 147783690000}, `{"label1":"value1","label2":"value2","_msg":"foo bar"}
+{"label1":"value1","label2":"value2","_msg":"abc"}
+{"label1":"value1","label2":"value2","_msg":"foobar"}`)
 
 	// Multiple streams
 	f(`{
@@ -118,12 +118,12 @@ _time:147783690000 "label1":"value1" "label2":"value2" "_msg":"foobar"`)
 			]
 		}
 	]
-}`, `_time:1577836800000000001 "foo":"bar" "a":"b" "_msg":"foo bar"
-_time:1577836900005000002 "foo":"bar" "a":"b" "_msg":"abc"
-_time:1877836900005000002 "x":"y" "_msg":"yx"`)
+}`, []int64{1577836800000000001, 1577836900005000002, 1877836900005000002}, `{"foo":"bar","a":"b","_msg":"foo bar"}
+{"foo":"bar","a":"b","_msg":"abc"}
+{"x":"y","_msg":"yx"}`)
 }
 
-func TestParsePromLabelsSuccess(t *testing.T) {
+func TestParsePromLabels_Success(t *testing.T) {
 	f := func(s string) {
 		t.Helper()
 		fields, err := parsePromLabels(nil, s)
@@ -147,7 +147,7 @@ func TestParsePromLabelsSuccess(t *testing.T) {
 	f(`{foo="ba\"r\\z\n", a="", b="\"\\"}`)
 }
 
-func TestParsePromLabelsFailure(t *testing.T) {
+func TestParsePromLabels_Failure(t *testing.T) {
 	f := func(s string) {
 		t.Helper()
 		fields, err := parsePromLabels(nil, s)
diff --git a/app/vlinsert/loki/loki_protobuf_timing_test.go b/app/vlinsert/loki/loki_protobuf_timing_test.go
index 981951765..6f873f11d 100644
--- a/app/vlinsert/loki/loki_protobuf_timing_test.go
+++ b/app/vlinsert/loki/loki_protobuf_timing_test.go
@@ -8,7 +8,7 @@ import (
 
 	"github.com/golang/snappy"
 
-	"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
+	"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutils"
 )
 
 func BenchmarkParseProtobufRequest(b *testing.B) {
@@ -24,12 +24,13 @@ func BenchmarkParseProtobufRequest(b *testing.B) {
 }
 
 func benchmarkParseProtobufRequest(b *testing.B, streams, rows, labels int) {
+	blp := &insertutils.BenchmarkLogMessageProcessor{}
 	b.ReportAllocs()
 	b.SetBytes(int64(streams * rows))
 	b.RunParallel(func(pb *testing.PB) {
 		body := getProtobufBody(streams, rows, labels)
 		for pb.Next() {
-			_, err := parseProtobufRequest(body, func(_ int64, _ []logstorage.Field) {})
+			_, err := parseProtobufRequest(body, blp)
 			if err != nil {
 				panic(fmt.Errorf("unexpected error: %w", err))
 			}
diff --git a/app/vlinsert/syslog/syslog_test.go b/app/vlinsert/syslog/syslog_test.go
index d07deab2f..b0069e9ec 100644
--- a/app/vlinsert/syslog/syslog_test.go
+++ b/app/vlinsert/syslog/syslog_test.go
@@ -1,65 +1,121 @@
 package syslog
 
 import (
-	"bufio"
 	"bytes"
 	"reflect"
 	"testing"
 	"time"
 
-	"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
+	"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutils"
 )
 
-func TestReadLine_Success(t *testing.T) {
-	f := func(data string, currentYear, rowsExpected int, timestampsExpected []int64, resultExpected string) {
+func TestSyslogLineReader_Success(t *testing.T) {
+	f := func(data string, linesExpected []string) {
 		t.Helper()
 
-		MustInit()
-		defer MustStop()
+		r := bytes.NewBufferString(data)
+		slr := getSyslogLineReader(r)
+		defer putSyslogLineReader(slr)
 
-		var timestamps []int64
-		var result string
-		processLogMessage := func(timestamp int64, fields []logstorage.Field) {
-			timestamps = append(timestamps, timestamp)
-			result += string(logstorage.MarshalFieldsToJSON(nil, fields)) + "\n"
+		var lines []string
+		for slr.nextLine() {
+			lines = append(lines, string(slr.line))
+		}
+		if err := slr.Error(); err != nil {
+			t.Fatalf("unexpected error: %s", err)
 		}
+		if !reflect.DeepEqual(lines, linesExpected) {
+			t.Fatalf("unexpected lines read;\ngot\n%q\nwant\n%q", lines, linesExpected)
+		}
+	}
+
+	f("", nil)
+	f("foobar", []string{"foobar"})
+
+	f(`Jun  3 12:08:33 abcd systemd: Starting Update the local ESM caches...
+48 <165>Jun  4 12:08:33 abcd systemd[345]: abc defg<123>1 2023-06-03T17:42:12.345Z mymachine.example.com appname 12345 ID47 [exampleSDID@32473 iut="3" eventSource="Application 123 = ] 56" eventID="11211"] This is a test message with structured data.
+`, []string{
+		"Jun  3 12:08:33 abcd systemd: Starting Update the local ESM caches...",
+		"<165>Jun  4 12:08:33 abcd systemd[345]: abc defg",
+		`<123>1 2023-06-03T17:42:12.345Z mymachine.example.com appname 12345 ID47 [exampleSDID@32473 iut="3" eventSource="Application 123 = ] 56" eventID="11211"] This is a test message with structured data.`,
+	})
+}
+
+func TestSyslogLineReader_Failure(t *testing.T) {
+	f := func(data string) {
+		t.Helper()
 
 		r := bytes.NewBufferString(data)
-		sc := bufio.NewScanner(r)
-		rows := 0
-		timezone := time.UTC
-		for {
-			ok, err := readLine(sc, currentYear, timezone, processLogMessage)
-			if err != nil {
-				t.Fatalf("unexpected error: %s", err)
-			}
-			if !ok {
-				break
-			}
-			rows++
+		slr := getSyslogLineReader(r)
+		defer putSyslogLineReader(slr)
+
+		if slr.nextLine() {
+			t.Fatalf("expecting failure to read the first line")
 		}
-		if rows != rowsExpected {
-			t.Fatalf("unexpected rows read; got %d; want %d", rows, rowsExpected)
+		if err := slr.Error(); err == nil {
+			t.Fatalf("expecting non-nil error")
 		}
+	}
+
+	// invalid format for message size
+	f("12foo bar")
+
+	// too big message size
+	f("123 aa")
+	f("1233423432 abc")
+}
+
+func TestProcessStreamInternal_Success(t *testing.T) {
+	f := func(data string, currentYear, rowsExpected int, timestampsExpected []int64, resultExpected string) {
+		t.Helper()
 
-		if !reflect.DeepEqual(timestamps, timestampsExpected) {
-			t.Fatalf("unexpected timestamps;\ngot\n%d\nwant\n%d", timestamps, timestampsExpected)
+		MustInit()
+		defer MustStop()
+
+		globalTimezone = time.UTC
+		globalCurrentYear.Store(int64(currentYear))
+
+		tlp := &insertutils.TestLogMessageProcessor{}
+		r := bytes.NewBufferString(data)
+		if err := processStreamInternal(r, "", tlp); err != nil {
+			t.Fatalf("unexpected error: %s", err)
 		}
-		if result != resultExpected {
-			t.Fatalf("unexpected result;\ngot\n%s\nwant\n%s", result, resultExpected)
+		if err := tlp.Verify(rowsExpected, timestampsExpected, resultExpected); err != nil {
+			t.Fatal(err)
 		}
 	}
 
 	data := `Jun  3 12:08:33 abcd systemd: Starting Update the local ESM caches...
-<165>Jun  4 12:08:33 abcd systemd[345]: abc defg
-<123>1 2023-06-03T17:42:12.345Z mymachine.example.com appname 12345 ID47 [exampleSDID@32473 iut="3" eventSource="Application 123 = ] 56" eventID="11211"] This is a test message with structured data.
+
+48 <165>Jun  4 12:08:33 abcd systemd[345]: abc defg<123>1 2023-06-03T17:42:12.345Z mymachine.example.com appname 12345 ID47 [exampleSDID@32473 iut="3" eventSource="Application 123 = ] 56" eventID="11211"] This is a test message with structured data.
 `
 	currentYear := 2023
 	rowsExpected := 3
 	timestampsExpected := []int64{1685794113000000000, 1685880513000000000, 1685814132345000000}
 	resultExpected := `{"format":"rfc3164","timestamp":"","hostname":"abcd","app_name":"systemd","_msg":"Starting Update the local ESM caches..."}
 {"priority":"165","facility":"20","severity":"5","format":"rfc3164","timestamp":"","hostname":"abcd","app_name":"systemd","proc_id":"345","_msg":"abc defg"}
-{"priority":"123","facility":"15","severity":"3","format":"rfc5424","timestamp":"","hostname":"mymachine.example.com","app_name":"appname","proc_id":"12345","msg_id":"ID47","exampleSDID@32473":"iut=\"3\" eventSource=\"Application 123 = ] 56\" eventID=\"11211\"","_msg":"This is a test message with structured data."}
-`
+{"priority":"123","facility":"15","severity":"3","format":"rfc5424","timestamp":"","hostname":"mymachine.example.com","app_name":"appname","proc_id":"12345","msg_id":"ID47","exampleSDID@32473":"iut=\"3\" eventSource=\"Application 123 = ] 56\" eventID=\"11211\"","_msg":"This is a test message with structured data."}`
 	f(data, currentYear, rowsExpected, timestampsExpected, resultExpected)
 }
+
+func TestProcessStreamInternal_Failure(t *testing.T) {
+	f := func(data string) {
+		t.Helper()
+
+		MustInit()
+		defer MustStop()
+
+		tlp := &insertutils.TestLogMessageProcessor{}
+		r := bytes.NewBufferString(data)
+		if err := processStreamInternal(r, "", tlp); err == nil {
+			t.Fatalf("expecting non-nil error")
+		}
+	}
+
+	// invalid format for message size
+	f("12foo bar")
+
+	// too big message size
+	f("123 foo")
+	f("123456789 bar")
+}
diff --git a/app/vmalert-tool/unittest/testdata/rules.yaml b/app/vmalert-tool/unittest/testdata/rules.yaml
index f7455c6d8..c69897ebe 100644
--- a/app/vmalert-tool/unittest/testdata/rules.yaml
+++ b/app/vmalert-tool/unittest/testdata/rules.yaml
@@ -37,3 +37,6 @@ groups:
     rules:
       - record: t3
         expr: t1
+
+  - name: emptyGroup
+    rules: []
diff --git a/app/vmalert-tool/unittest/unittest.go b/app/vmalert-tool/unittest/unittest.go
index aaf61af68..534153524 100644
--- a/app/vmalert-tool/unittest/unittest.go
+++ b/app/vmalert-tool/unittest/unittest.go
@@ -9,10 +9,12 @@ import (
 	"path/filepath"
 	"reflect"
 	"sort"
+	"strings"
 	"time"
 
 	"gopkg.in/yaml.v2"
 
+	"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/config"
 	vmalertconfig "github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/config"
 	"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/datasource"
 	"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/notifier"
@@ -66,36 +68,38 @@ func UnitTest(files []string, disableGroupLabel bool) bool {
 	defer vminsert.Stop()
 	defer vmselect.Stop()
 	disableAlertgroupLabel = disableGroupLabel
-	return rulesUnitTest(files)
-}
 
-func rulesUnitTest(files []string) bool {
+	testfiles, err := config.ReadFromFS(files)
+	if err != nil {
+		fmt.Println("  FAILED")
+		fmt.Printf("\nfailed to read test files: \n%v", err)
+	}
 	var failed bool
-	for _, f := range files {
-		if err := ruleUnitTest(f); err != nil {
+	for fileName, file := range testfiles {
+		if err := ruleUnitTest(fileName, file); err != nil {
 			fmt.Println("  FAILED")
-			fmt.Printf("\nfailed to run unit test for file %q: \n%v", f, err)
+			fmt.Printf("\nfailed to run unit test for file %q: \n%v", file, err)
 			failed = true
 		} else {
 			fmt.Println("  SUCCESS")
 		}
 	}
+
 	return failed
 }
 
-func ruleUnitTest(filename string) []error {
+func ruleUnitTest(filename string, content []byte) []error {
 	fmt.Println("\nUnit Testing: ", filename)
-	b, err := os.ReadFile(filename)
-	if err != nil {
-		return []error{fmt.Errorf("failed to read file: %w", err)}
-	}
-
 	var unitTestInp unitTestFile
-	if err := yaml.UnmarshalStrict(b, &unitTestInp); err != nil {
+	if err := yaml.UnmarshalStrict(content, &unitTestInp); err != nil {
 		return []error{fmt.Errorf("failed to unmarshal file: %w", err)}
 	}
-	if err := resolveAndGlobFilepaths(filepath.Dir(filename), &unitTestInp); err != nil {
-		return []error{fmt.Errorf("failed to resolve path for `rule_files`: %w", err)}
+
+	// add file directory for rule files if needed
+	for i, rf := range unitTestInp.RuleFiles {
+		if rf != "" && !filepath.IsAbs(rf) && !strings.HasPrefix(rf, "http") {
+			unitTestInp.RuleFiles[i] = filepath.Join(filepath.Dir(filename), rf)
+		}
 	}
 
 	if unitTestInp.EvaluationInterval.Duration() == 0 {
@@ -139,24 +143,24 @@ func verifyTestGroup(group testGroup) error {
 	}
 	for _, at := range group.AlertRuleTests {
 		if at.Alertname == "" {
-			return fmt.Errorf("\n%s    missing required filed \"alertname\"", testGroupName)
+			return fmt.Errorf("\n%s    missing required field \"alertname\"", testGroupName)
 		}
 		if !disableAlertgroupLabel && at.GroupName == "" {
-			return fmt.Errorf("\n%s    missing required filed \"groupname\" when flag \"disableAlertGroupLabel\" is false", testGroupName)
+			return fmt.Errorf("\n%s    missing required field \"groupname\" when flag \"disableAlertgroupLabel\" is false", testGroupName)
 		}
 		if disableAlertgroupLabel && at.GroupName != "" {
-			return fmt.Errorf("\n%s    shouldn't set filed \"groupname\" when flag \"disableAlertGroupLabel\" is true", testGroupName)
+			return fmt.Errorf("\n%s    shouldn't set field \"groupname\" when flag \"disableAlertgroupLabel\" is true", testGroupName)
 		}
 		if at.EvalTime == nil {
-			return fmt.Errorf("\n%s    missing required filed \"eval_time\"", testGroupName)
+			return fmt.Errorf("\n%s    missing required field \"eval_time\"", testGroupName)
 		}
 	}
 	for _, et := range group.MetricsqlExprTests {
 		if et.Expr == "" {
-			return fmt.Errorf("\n%s    missing required filed \"expr\"", testGroupName)
+			return fmt.Errorf("\n%s    missing required field \"expr\"", testGroupName)
 		}
 		if et.EvalTime == nil {
-			return fmt.Errorf("\n%s    missing required filed \"eval_time\"", testGroupName)
+			return fmt.Errorf("\n%s    missing required field \"eval_time\"", testGroupName)
 		}
 	}
 	return nil
@@ -235,30 +239,6 @@ func tearDown() {
 	fs.MustRemoveAll(storagePath)
 }
 
-// resolveAndGlobFilepaths joins all relative paths in a configuration
-// with a given base directory and replaces all globs with matching files.
-func resolveAndGlobFilepaths(baseDir string, utf *unitTestFile) error {
-	for i, rf := range utf.RuleFiles {
-		if rf != "" && !filepath.IsAbs(rf) {
-			utf.RuleFiles[i] = filepath.Join(baseDir, rf)
-		}
-	}
-
-	var globbedFiles []string
-	for _, rf := range utf.RuleFiles {
-		m, err := filepath.Glob(rf)
-		if err != nil {
-			return err
-		}
-		if len(m) == 0 {
-			fmt.Fprintln(os.Stderr, "  WARNING: no file match pattern", rf)
-		}
-		globbedFiles = append(globbedFiles, m...)
-	}
-	utf.RuleFiles = globbedFiles
-	return nil
-}
-
 func (tg *testGroup) test(evalInterval time.Duration, groupOrderMap map[string]int, testGroups []vmalertconfig.Group) (checkErrs []error) {
 	// set up vmstorage and http server for ingest and read queries
 	setUp()
@@ -316,6 +296,9 @@ func (tg *testGroup) test(evalInterval time.Duration, groupOrderMap map[string]i
 	maxEvalTime := testStartTime.Add(tg.maxEvalTime())
 	for ts := testStartTime; ts.Before(maxEvalTime) || ts.Equal(maxEvalTime); ts = ts.Add(evalInterval) {
 		for _, g := range groups {
+			if len(g.Rules) == 0 {
+				continue
+			}
 			errs := g.ExecOnce(context.Background(), func() []notifier.Notifier { return nil }, rw, ts)
 			for err := range errs {
 				if err != nil {
@@ -374,7 +357,7 @@ func (tg *testGroup) test(evalInterval time.Duration, groupOrderMap map[string]i
 						if expAlert.ExpLabels == nil {
 							expAlert.ExpLabels = make(map[string]string)
 						}
-						// alertGroupNameLabel is added as additional labels when `disableAlertGroupLabel` is false
+						// alertGroupNameLabel is added as additional labels when `disableAlertgroupLabel` is false
 						if !disableAlertgroupLabel {
 							expAlert.ExpLabels["alertgroup"] = groupname
 						}
diff --git a/lib/logstorage/tenant_id_test.go b/lib/logstorage/tenant_id_test.go
index 6969a5490..f440e5d8e 100644
--- a/lib/logstorage/tenant_id_test.go
+++ b/lib/logstorage/tenant_id_test.go
@@ -123,11 +123,11 @@ func TestTenantIDLessEqual(t *testing.T) {
 	}
 }
 
-func Test_GetTenantIDFromString(t *testing.T) {
+func TestParseTenantID(t *testing.T) {
 	f := func(tenant string, expected TenantID) {
 		t.Helper()
 
-		got, err := GetTenantIDFromString(tenant)
+		got, err := ParseTenantID(tenant)
 		if err != nil {
 			t.Errorf("unexpected error: %s", err)
 			return