Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 6 additions & 15 deletions app/vtselect/traces/query/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ func findTraceIDsSplitTimeRange(ctx context.Context, q *logstorage.Query, cp *tr
var startTimeLock sync.Mutex

traceIDList := make([]string, 0, limit)
maxStartTimeStr := endTime.Format(time.RFC3339)
maxStartTimeNs := endTime.UnixNano()

cp.Query = q
qctx := cp.NewQueryContext(ctx)
Expand All @@ -301,8 +301,8 @@ func findTraceIDsSplitTimeRange(ctx context.Context, q *logstorage.Query, cp *tr
case "_time":
startTimeLock.Lock()
for _, v := range columns[i].Values {
if v < maxStartTimeStr {
maxStartTimeStr = strings.Clone(v)
if ns, ok := logstorage.TryParseTimestampRFC3339Nano(v); ok && ns < maxStartTimeNs {
maxStartTimeNs = ns
}
}
startTimeLock.Unlock()
Expand All @@ -322,11 +322,7 @@ func findTraceIDsSplitTimeRange(ctx context.Context, q *logstorage.Query, cp *tr

// found enough trace_id, return directly
if len(traceIDList) == limit {
maxStartTime, err := time.Parse(time.RFC3339, maxStartTimeStr)
if err != nil {
return nil, maxStartTime, err
}
return checkTraceIDList(traceIDList), maxStartTime, nil
return checkTraceIDList(traceIDList), time.Unix(0, maxStartTimeNs), nil
}

// not enough trace_id, clear the result, extend the time range and try again.
Expand All @@ -346,12 +342,7 @@ func findTraceIDsSplitTimeRange(ctx context.Context, q *logstorage.Query, cp *tr
return nil, time.Time{}, err
}

maxStartTime, err := time.Parse(time.RFC3339, maxStartTimeStr)
if err != nil {
return nil, maxStartTime, err
}

return checkTraceIDList(traceIDList), maxStartTime, nil
return checkTraceIDList(traceIDList), time.Unix(0, maxStartTimeNs), nil
}

// findTraceIDTimeSplitTimeRange try to search from {trace_id_idx_stream="xx"} stream, which contains
Expand Down Expand Up @@ -438,7 +429,7 @@ func findTraceIDTimeSplitTimeRange(ctx context.Context, q *logstorage.Query, cp
// this could be the old format index, which records trace ID and the approximate timestamp only.
// to transform this into new format (start time & end time), use [t-traceWindow, t+traceWindow].
// this code should be deprecated in the future.
timestamp, _ := time.Parse(time.RFC3339, timeStr)
timestamp, _ := time.Parse(time.RFC3339Nano, timeStr)
return timestamp.Add(-*tracecommon.TraceMaxDurationWindow), timestamp.Add(*tracecommon.TraceMaxDurationWindow), nil
}

Expand Down
21 changes: 6 additions & 15 deletions app/vtselect/traces/tempo/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ func findTraceIDsSplitTimeRange(ctx context.Context, q *logstorage.Query, cp *tr
var traceIDListLock sync.Mutex
var startTimeLock sync.Mutex
traceIDList := make([]string, 0, limit)
maxStartTimeStr := endTime.Format(time.RFC3339)
maxStartTimeNs := endTime.UnixNano()

cp.Query = q
qctx := cp.NewQueryContext(ctx)
Expand All @@ -215,8 +215,8 @@ func findTraceIDsSplitTimeRange(ctx context.Context, q *logstorage.Query, cp *tr
case "_time":
startTimeLock.Lock()
for _, v := range columns[i].Values {
if v < maxStartTimeStr {
maxStartTimeStr = strings.Clone(v)
if ns, ok := logstorage.TryParseTimestampRFC3339Nano(v); ok && ns < maxStartTimeNs {
maxStartTimeNs = ns
}
}
startTimeLock.Unlock()
Expand All @@ -236,11 +236,7 @@ func findTraceIDsSplitTimeRange(ctx context.Context, q *logstorage.Query, cp *tr

// found enough trace_id, return directly
if len(traceIDList) == limit {
maxStartTime, err := time.Parse(time.RFC3339, maxStartTimeStr)
if err != nil {
return nil, maxStartTime, err
}
return checkTraceIDList(traceIDList), maxStartTime, nil
return checkTraceIDList(traceIDList), time.Unix(0, maxStartTimeNs), nil
}

// not enough trace_id, clear the result, extend the time range and try again.
Expand All @@ -260,12 +256,7 @@ func findTraceIDsSplitTimeRange(ctx context.Context, q *logstorage.Query, cp *tr
return nil, time.Time{}, err
}

maxStartTime, err := time.Parse(time.RFC3339, maxStartTimeStr)
if err != nil {
return nil, maxStartTime, err
}

return checkTraceIDList(traceIDList), maxStartTime, nil
return checkTraceIDList(traceIDList), time.Unix(0, maxStartTimeNs), nil
}

// findTraceIDTimeSplitTimeRange try to search from {trace_id_idx_stream="xx"} stream, which contains
Expand Down Expand Up @@ -365,7 +356,7 @@ func findTraceIDTimeSplitTimeRange(ctx context.Context, q *logstorage.Query, cp
// this could be the old format index, which records trace ID and the approximate timestamp only.
// to transform this into new format (start time & end time), use [t-traceWindow, t+traceWindow].
// this code should be deprecated in the future.
timestamp, _ := time.Parse(time.RFC3339, timeStr)
timestamp, _ := time.Parse(time.RFC3339Nano, timeStr)
return timestamp.Add(-*tracecommon.TraceMaxDurationWindow), timestamp.Add(*tracecommon.TraceMaxDurationWindow), nil
}

Expand Down
4 changes: 3 additions & 1 deletion apptest/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ type app struct {
}

// appOptions holds the optional configuration of an app, such as default flags
// to set and things to extract from the app's log.
// to set, extra environment variables and things to extract from the app's log.
type appOptions struct {
defaultFlags map[string]string
env []string
extractREs []*regexp.Regexp
wait bool
}
Expand All @@ -54,6 +55,7 @@ func startApp(instance string, binary string, flags []string, opts *appOptions)
flags = setDefaultFlags(flags, opts.defaultFlags)

cmd := exec.Command(binary, flags...)
cmd.Env = append(os.Environ(), opts.env...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, nil, err
Expand Down
24 changes: 24 additions & 0 deletions apptest/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,30 @@ func NewJaegerAPITracesResponse(t *testing.T, s string) *JaegerAPITracesResponse
return res
}

// TempoAPISearchResponse is an in-memory representation of the
// /select/tempo/api/search response.
type TempoAPISearchResponse struct {
Traces []TempoTraceSummary `json:"traces"`
}

// TempoTraceSummary is the structure of an item in the `traces` field of the
// /select/tempo/api/search response.
type TempoTraceSummary struct {
TraceID string `json:"traceID"`
}

// NewTempoAPISearchResponse is a test helper function that creates a new
// instance of TempoAPISearchResponse by unmarshalling a json string.
func NewTempoAPISearchResponse(t *testing.T, s string) *TempoAPISearchResponse {
t.Helper()

res := &TempoAPISearchResponse{}
if err := json.Unmarshal([]byte(s), res); err != nil {
t.Fatalf("could not unmarshal query response data=\n%s\n: %v", s, err)
}
return res
}

// NewJaegerAPITraceResponse is a test helper function that creates a new
// instance of JaegerAPITraceResponse by unmarshalling a json string.
func NewJaegerAPITraceResponse(t *testing.T, s string) *JaegerAPITraceResponse {
Expand Down
8 changes: 4 additions & 4 deletions apptest/testcase.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,10 @@ func (tc *TestCase) MustStartDefaultVtsingle() *Vtsingle {

// MustStartVtsingle is a test helper function that starts an instance of
// vtsingle and fails the test if the app fails to start.
func (tc *TestCase) MustStartVtsingle(instance string, flags []string) *Vtsingle {
func (tc *TestCase) MustStartVtsingle(instance string, flags []string, env ...string) *Vtsingle {

@cubic-dev-ai cubic-dev-ai Bot Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Merging MustStartVtsingleWithEnv back into MustStartVtsingle silently drops the documentation of the new env parameter: the removed helper's doc comment explicitly explained that environment variables such as TZ are passed to the process, while MustStartVtsingle (and MustStartVtagent) now take env ...string without any doc mention. Mention the optional env in both doc comments so tests that need a non-UTC TZ are discoverable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apptest/testcase.go, line 167:

<comment>Merging `MustStartVtsingleWithEnv` back into `MustStartVtsingle` silently drops the documentation of the new `env` parameter: the removed helper's doc comment explicitly explained that environment variables such as `TZ` are passed to the process, while `MustStartVtsingle` (and `MustStartVtagent`) now take `env ...string` without any doc mention. Mention the optional `env` in both doc comments so tests that need a non-UTC `TZ` are discoverable.</comment>

<file context>
@@ -164,15 +164,7 @@ func (tc *TestCase) MustStartDefaultVtsingle() *Vtsingle {
-// MustStartVtsingleWithEnv is like MustStartVtsingle, but also passes the given
-// environment variables, such as TZ, to the vtsingle process.
-func (tc *TestCase) MustStartVtsingleWithEnv(instance string, flags, env []string) *Vtsingle {
+func (tc *TestCase) MustStartVtsingle(instance string, flags []string, env ...string) *Vtsingle {
 	tc.t.Helper()
 
</file context>
Fix with cubic

tc.t.Helper()

app, err := StartVtsingle(instance, flags, tc.cli)
app, err := StartVtsingle(instance, flags, env, tc.cli)
if err != nil {
tc.t.Fatalf("Could not start %s: %v", instance, err)
}
Expand All @@ -177,10 +177,10 @@ func (tc *TestCase) MustStartVtsingle(instance string, flags []string) *Vtsingle

// MustStartVtagent is a test helper function that starts an instance of
// vtagent and fails the test if the app fails to start.
func (tc *TestCase) MustStartVtagent(instance string, remoteWriteURLs, flags []string) *Vtagent {
func (tc *TestCase) MustStartVtagent(instance string, remoteWriteURLs, flags []string, env ...string) *Vtagent {
tc.t.Helper()

app, err := StartVtagent(instance, remoteWriteURLs, flags, tc.cli)
app, err := StartVtagent(instance, remoteWriteURLs, flags, env, tc.cli)
if err != nil {
tc.t.Fatalf("Could not start %s: %v", instance, err)
}
Expand Down
81 changes: 81 additions & 0 deletions apptest/tests/search_timezone_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package tests

import (
"fmt"
"testing"
"time"

"github.com/VictoriaMetrics/VictoriaTraces/app/vtselect/traces/query"
at "github.com/VictoriaMetrics/VictoriaTraces/apptest"
otelpb "github.com/VictoriaMetrics/VictoriaTraces/lib/protoparser/opentelemetry/pb"
)

// TestSingleSearchTracesNonUTCTimezone verifies that Jaeger and Tempo trace search return correct traces
// when VictoriaTraces runs in a time zone other than UTC.
//
// See https://github.com/VictoriaMetrics/VictoriaTraces/issues/260
func TestSingleSearchTracesNonUTCTimezone(t *testing.T) {
tc := at.NewTestCase(t)
defer tc.Stop()

sut := tc.MustStartVtsingle("vtsingle", []string{
"-storageDataPath=" + tc.Dir() + "/vtsingle",
"-retentionPeriod=100y",
}, []string{"TZ=America/New_York"}...)

serviceName := "testSearchTimezoneService"
spanTime := time.Now().Add(-2 * time.Hour)
req := &otelpb.ExportTraceServiceRequest{
ResourceSpans: []*otelpb.ResourceSpans{
{
Resource: otelpb.Resource{
Attributes: []*otelpb.KeyValue{
{Key: "service.name", Value: &otelpb.AnyValue{StringValue: &serviceName}},
},
},
ScopeSpans: []*otelpb.ScopeSpans{
{
Spans: []*otelpb.Span{
{
TraceID: "5b8efff798038103d269b633813fc60c",
SpanID: "eee19b7ec3c1b174",
Name: "testSearchTimezoneSpan",
StartTimeUnixNano: uint64(spanTime.UnixNano()),
EndTimeUnixNano: uint64(spanTime.Add(time.Millisecond).UnixNano()),
},
},
},
},
},
},
}
sut.OTLPHTTPExportTraces(t, req, at.QueryOpts{})
sut.ForceFlush(t)

startTime := spanTime.Add(-time.Hour)
endTime := time.Now()

tc.Assert(&at.AssertOptions{
Msg: "unexpected number of traces from /select/jaeger/api/traces",
Got: func() any {
res := sut.JaegerAPITraces(t, at.JaegerQueryParam{
TraceQueryParam: query.TraceQueryParam{
ServiceName: serviceName,
StartTimeMin: startTime,
StartTimeMax: endTime,
},
}, at.QueryOpts{})
return len(res.Data)
},
Want: 1,
})

tc.Assert(&at.AssertOptions{
Msg: "unexpected number of traces from /select/tempo/api/search",
Got: func() any {
res := sut.TempoAPISearch(t, fmt.Sprintf(`{resource.service.name=%q}`, serviceName), startTime, endTime)
return len(res.Traces)
},
Want: 1,
})
}
3 changes: 2 additions & 1 deletion apptest/vtagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ type Vtagent struct {
// StartVtagent starts an instance of Vtagent with the given flags.
// It also sets the default flags and populates the app instance state with
// values extracted from the application log (such as httpListenAddr).
func StartVtagent(instance string, remoteWriteURLs, flags []string, cli *Client) (*Vtagent, error) {
func StartVtagent(instance string, remoteWriteURLs, flags, env []string, cli *Client) (*Vtagent, error) {
app, stderrExtracts, err := startApp(instance, "../../bin/vtagent-race", flags, &appOptions{
env: env,
defaultFlags: map[string]string{
"-httpListenAddr": "127.0.0.1:0",
"-otlpGRPCListenAddr": "127.0.0.1:0",
Expand Down
29 changes: 25 additions & 4 deletions apptest/vtsingle.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/url"
"os"
"regexp"
"strconv"
"testing"
"time"

Expand All @@ -32,17 +33,21 @@ type Vtsingle struct {
jaegerAPITraceURL string
jaegerAPIDependenciesURL string

tempoAPISearchURL string

logsQLQueryURL string

otlpTracesURL string
otlpGRPCTracesURL string
}

// StartVtsingle starts an instance of Vtsingle with the given flags. It also
// sets the default flags and populates the app instance state with runtime
// values extracted from the application log (such as httpListenAddr).
func StartVtsingle(instance string, flags []string, cli *Client) (*Vtsingle, error) {
// StartVtsingle starts an instance of Vtsingle with the given flags and extra
// environment variables. It also sets the default flags and populates the app
// instance state with runtime values extracted from the application log (such
// as httpListenAddr).
func StartVtsingle(instance string, flags, env []string, cli *Client) (*Vtsingle, error) {
app, stderrExtracts, err := startApp(instance, "../../bin/victoria-traces-race", flags, &appOptions{
env: env,
defaultFlags: map[string]string{
"-storageDataPath": fmt.Sprintf("%s/%s-%d", os.TempDir(), instance, time.Now().UnixNano()),
"-httpListenAddr": "127.0.0.1:0",
Expand Down Expand Up @@ -79,6 +84,8 @@ func StartVtsingle(instance string, flags []string, cli *Client) (*Vtsingle, err
jaegerAPITraceURL: fmt.Sprintf("http://%s/select/jaeger/api/traces/%%s", stderrExtracts[1]),
jaegerAPIDependenciesURL: fmt.Sprintf("http://%s/select/jaeger/api/dependencies", stderrExtracts[1]),

tempoAPISearchURL: fmt.Sprintf("http://%s/select/tempo/api/search", stderrExtracts[1]),

logsQLQueryURL: fmt.Sprintf("http://%s/select/logsql/query", stderrExtracts[1]),

otlpTracesURL: fmt.Sprintf("http://%s/insert/opentelemetry/v1/traces", stderrExtracts[1]),
Expand Down Expand Up @@ -157,6 +164,20 @@ func (app *Vtsingle) JaegerAPITrace(t *testing.T, traceID string, opts QueryOpts
return NewJaegerAPITraceResponse(t, res)
}

// TempoAPISearch is a test helper function that searches for traces with a TraceQL query
// by sending an HTTP GET request to /select/tempo/api/search Vtsingle endpoint.
func (app *Vtsingle) TempoAPISearch(t *testing.T, q string, start, end time.Time) *TempoAPISearchResponse {
t.Helper()

uv := url.Values{
"q": {q},
"start": {strconv.FormatInt(start.Unix(), 10)},
"end": {strconv.FormatInt(end.Unix(), 10)},
}
res, _ := app.cli.Get(t, app.tempoAPISearchURL+"?"+uv.Encode())
return NewTempoAPISearchResponse(t, res)
}

// JaegerAPIDependencies is a test helper function that queries for the dependencies.
// This method is not implemented in Vtsingle and this test is no-op for now.
func (app *Vtsingle) JaegerAPIDependencies(t *testing.T, param JaegerDependenciesParam, opts QueryOpts) *JaegerAPIDependenciesResponse {
Expand Down
2 changes: 2 additions & 0 deletions docs/victoriatraces/changelog/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ The following `tip` changes can be tested by building VictoriaTraces components

## tip

* BUGFIX: [Single-node VictoriaTraces](https://docs.victoriametrics.com/victoriatraces/) and vtselect in [VictoriaTraces cluster](https://docs.victoriametrics.com/victoriatraces/cluster/): apply correct time‑range filter and return proper traces from Jaeger and Tempo search APIs when VictoriaTraces runs in a non‑UTC time zone. Previously, searches could return fewer traces or empty results due to incorrect time‑zone conversion for the time‑range filter, especially for time zones west of UTC such as `TZ=America/New_York`, which impacted the end‑time boundary. See [issue #260](https://github.com/VictoriaMetrics/VictoriaTraces/issues/260). Thank @Vandit1604 for [pull request #262](https://github.com/VictoriaMetrics/VictoriaTraces/pull/262).

## [v0.12.0](https://github.com/VictoriaMetrics/VictoriaTraces/releases/tag/v0.12.0)

Released at 2026-09-29
Expand Down
Loading