Skip to content

Commit 018ddfe

Browse files
committed
fix(monit-query): normalize raw-mode time args for loki/victorialogs
monit-query rows' raw-mode time window is passed as free-form --args <ds-type>.start / <ds-type>.end strings, forwarded to the server without any client-side parsing. The raw query path requires unix seconds and rejects any other format, while the stats path silently ignores these keys entirely — so a value that appeared to work under stats mode would 400 once raw mode actually validated it. Add normalizeRawTimeArgs to convert <ds-type>.start/.end into unix seconds before the request is sent, for the loki and victorialogs ds-types that consume this args contract. It reuses timeutil.Parse, the same helper already used for diagnose's --time-start/--time-end, so relative durations, dates, RFC3339 (with or without an offset), and unix seconds are all accepted uniformly across both commands. timeutil.Parse also gains unix-millisecond support: a bare numeric value that would only be a plausible unix-seconds timestamp past the year 5138 is now treated as milliseconds and divided down, rather than passed through as seconds. Updated --args, --time-start, and --time-end help text to document the accepted formats.
1 parent 6344780 commit 018ddfe

4 files changed

Lines changed: 193 additions & 8 deletions

File tree

internal/cli/monit_query.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cli
33
import (
44
"encoding/json"
55
"fmt"
6+
"strconv"
67

78
"github.com/flashcatcloud/go-flashduty"
89
"github.com/spf13/cobra"
@@ -70,8 +71,8 @@ func newMonitQueryDiagnoseCmd() *cobra.Command {
7071
cmd.Flags().StringVar(&dsType, "ds-type", "", "Datasource type: prometheus|victorialogs|loki|mysql (required)")
7172
cmd.Flags().StringVar(&dsName, "ds-name", "", "Datasource name as configured (required)")
7273
registerEnumFlag(cmd, "ds-type", "prometheus", "victorialogs", "loki", "mysql")
73-
cmd.Flags().StringVar(&timeStart, "time-start", "15m", "Window start (relative '15m'/'1h', unix seconds, or 'now')")
74-
cmd.Flags().StringVar(&timeEnd, "time-end", "now", "Window end (relative, unix seconds, or 'now'; span capped at 6h)")
74+
cmd.Flags().StringVar(&timeStart, "time-start", "15m", "Window start: relative duration ('15m'/'1h'), 'now', a date/RFC3339 timestamp, or a unix epoch in seconds or milliseconds")
75+
cmd.Flags().StringVar(&timeEnd, "time-end", "now", "Window end: same formats as --time-start; span capped at 6h")
7576
cmd.Flags().StringVar(&inputQuery, "input-query", "", "Filter-only log query OR matrix PromQL (required)")
7677
cmd.Flags().StringVar(&operation, "operation", "", "log_patterns or metric_trends (default inferred from ds-type)")
7778
cmd.Flags().IntVar(&maxLogs, "max-logs", 0, "Max log lines scanned (default 10000, cap 50000)")
@@ -99,6 +100,9 @@ func newMonitQueryRowsCmd() *cobra.Command {
99100
if err != nil {
100101
return fmt.Errorf("invalid --args: %w", err)
101102
}
103+
if err := normalizeRawTimeArgs(dsType, argsMap); err != nil {
104+
return err
105+
}
102106

103107
return runCommand(cmd, args, func(ctx *RunContext) error {
104108
input := &flashduty.QueryRowsRequest{
@@ -136,7 +140,35 @@ func newMonitQueryRowsCmd() *cobra.Command {
136140
cmd.Flags().StringVar(&dsName, "ds-name", "", "Datasource name (required)")
137141
registerEnumFlag(cmd, "ds-type", "prometheus", "victorialogs", "loki", "mysql")
138142
cmd.Flags().StringVar(&expr, "expr", "", "Query expression (required)")
139-
cmd.Flags().StringSliceVar(&argsKV, "args", nil, "Arg entries KEY=VALUE (repeatable; values must be strings per monit-query contract)")
143+
cmd.Flags().StringSliceVar(&argsKV, "args", nil, "Arg entries KEY=VALUE (repeatable; values must be strings per monit-query contract). "+
144+
"For loki/victorialogs raw mode, <ds-type>.start/<ds-type>.end accept a relative duration ('15m'), 'now', a date/RFC3339 timestamp, "+
145+
"or a unix epoch in seconds or milliseconds — normalized to the form the datasource requires before sending")
140146

141147
return cmd
142148
}
149+
150+
// normalizeRawTimeArgs rewrites the raw-mode time-window args of a
151+
// monit-query rows call (<ds-type>.start / <ds-type>.end) into the unix-
152+
// seconds form the server requires, accepting any format timeutil.Parse
153+
// understands (RFC3339, date/datetime, relative duration, unix seconds or
154+
// milliseconds). Loki and VictoriaLogs are the only ds-types whose raw mode
155+
// consumes these keys; other ds-types ignore args entirely, so nothing is
156+
// touched for them.
157+
func normalizeRawTimeArgs(dsType string, args map[string]string) error {
158+
if dsType != "loki" && dsType != "victorialogs" {
159+
return nil
160+
}
161+
for _, suffix := range []string{"start", "end"} {
162+
key := dsType + "." + suffix
163+
v, ok := args[key]
164+
if !ok || v == "" {
165+
continue
166+
}
167+
ts, err := timeutil.Parse(v)
168+
if err != nil {
169+
return fmt.Errorf("invalid --args %s=%s: %w", key, v, err)
170+
}
171+
args[key] = strconv.FormatInt(ts, 10)
172+
}
173+
return nil
174+
}

internal/cli/monit_query_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package cli
33
import (
44
"encoding/json"
55
"fmt"
6+
"strconv"
67
"strings"
78
"testing"
9+
"time"
810
)
911

1012
func TestMonitQueryDiagnoseFlags(t *testing.T) {
@@ -290,6 +292,120 @@ func TestMonitQueryRowsRequiredFlags(t *testing.T) {
290292
}
291293
}
292294

295+
// --- normalizeRawTimeArgs --------------------------------------------------
296+
297+
func TestNormalizeRawTimeArgsAcceptedFormats(t *testing.T) {
298+
cases := []struct {
299+
name string
300+
input string
301+
}{
302+
{"rfc3339 utc", "2026-08-11T09:40:00Z"},
303+
{"rfc3339 offset", "2026-08-11T09:40:00+08:00"},
304+
{"unix seconds", "1786497600"},
305+
{"unix milliseconds", "1786497600000"},
306+
}
307+
for _, tc := range cases {
308+
t.Run(tc.name, func(t *testing.T) {
309+
args := map[string]string{"victorialogs.start": tc.input, "victorialogs.end": tc.input}
310+
if err := normalizeRawTimeArgs("victorialogs", args); err != nil {
311+
t.Fatalf("normalizeRawTimeArgs(%q): unexpected error: %v", tc.input, err)
312+
}
313+
for _, key := range []string{"victorialogs.start", "victorialogs.end"} {
314+
if _, err := strconv.ParseInt(args[key], 10, 64); err != nil {
315+
t.Errorf("%s: expected normalized unix-seconds string, got %q", key, args[key])
316+
}
317+
}
318+
})
319+
}
320+
}
321+
322+
func TestNormalizeRawTimeArgsLokiPrefix(t *testing.T) {
323+
args := map[string]string{"loki.start": "2026-08-11T09:40:00Z", "loki.end": "2026-08-11T10:05:00Z"}
324+
if err := normalizeRawTimeArgs("loki", args); err != nil {
325+
t.Fatalf("unexpected error: %v", err)
326+
}
327+
wantStart := strconv.FormatInt(time.Date(2026, 8, 11, 9, 40, 0, 0, time.UTC).Unix(), 10)
328+
wantEnd := strconv.FormatInt(time.Date(2026, 8, 11, 10, 5, 0, 0, time.UTC).Unix(), 10)
329+
if args["loki.start"] != wantStart || args["loki.end"] != wantEnd {
330+
t.Errorf("unexpected normalized loki args: %#v, want start=%s end=%s", args, wantStart, wantEnd)
331+
}
332+
}
333+
334+
func TestNormalizeRawTimeArgsIgnoresOtherDsTypes(t *testing.T) {
335+
args := map[string]string{"prometheus.start": "not-a-time"}
336+
if err := normalizeRawTimeArgs("prometheus", args); err != nil {
337+
t.Fatalf("unexpected error: %v", err)
338+
}
339+
if args["prometheus.start"] != "not-a-time" {
340+
t.Errorf("expected prometheus args untouched, got %#v", args)
341+
}
342+
}
343+
344+
func TestNormalizeRawTimeArgsIgnoresUnrelatedKeys(t *testing.T) {
345+
args := map[string]string{"victorialogs.type": "raw", "victorialogs.timespan.value": "15"}
346+
if err := normalizeRawTimeArgs("victorialogs", args); err != nil {
347+
t.Fatalf("unexpected error: %v", err)
348+
}
349+
if args["victorialogs.type"] != "raw" || args["victorialogs.timespan.value"] != "15" {
350+
t.Errorf("expected unrelated args untouched, got %#v", args)
351+
}
352+
}
353+
354+
func TestNormalizeRawTimeArgsInvalidValue(t *testing.T) {
355+
args := map[string]string{"victorialogs.start": "not-a-time"}
356+
err := normalizeRawTimeArgs("victorialogs", args)
357+
if err == nil {
358+
t.Fatal("expected error for invalid victorialogs.start, got nil")
359+
}
360+
if !strings.Contains(err.Error(), "victorialogs.start") {
361+
t.Errorf("expected error to mention victorialogs.start, got %q", err.Error())
362+
}
363+
}
364+
365+
// TestMonitQueryRowsRawModeNormalizesRFC3339 is the regression test for the
366+
// raw-vs-stats time format inconsistency: a raw-mode VictoriaLogs query given
367+
// RFC3339 --args timestamps must reach the server as the unix-seconds form
368+
// the raw query path requires.
369+
func TestMonitQueryRowsRawModeNormalizesRFC3339(t *testing.T) {
370+
saveAndResetGlobals(t)
371+
stub := newGFStub(t)
372+
stub.data = []any{}
373+
374+
_, err := execCommand(
375+
"monit-query", "rows",
376+
"--ds-type", "victorialogs",
377+
"--ds-name", "vl-prod",
378+
"--expr", `{app="api"} |= "error"`,
379+
"--args", "victorialogs.type=raw",
380+
"--args", "victorialogs.start=2026-08-11T09:40:00Z",
381+
"--args", "victorialogs.end=2026-08-11T10:05:00Z",
382+
)
383+
if err != nil {
384+
t.Fatalf("unexpected error: %v", err)
385+
}
386+
body := stub.lastBody
387+
argsSent, _ := body["args"].(map[string]any)
388+
start, ok := argsSent["victorialogs.start"].(string)
389+
if !ok {
390+
t.Fatalf("expected victorialogs.start in request args, got %#v", argsSent)
391+
}
392+
if _, err := strconv.ParseInt(start, 10, 64); err != nil {
393+
t.Errorf("expected victorialogs.start to be unix-seconds, got %q", start)
394+
}
395+
end, ok := argsSent["victorialogs.end"].(string)
396+
if !ok {
397+
t.Fatalf("expected victorialogs.end in request args, got %#v", argsSent)
398+
}
399+
if _, err := strconv.ParseInt(end, 10, 64); err != nil {
400+
t.Errorf("expected victorialogs.end to be unix-seconds, got %q", end)
401+
}
402+
wantStart := time.Date(2026, 8, 11, 9, 40, 0, 0, time.UTC).Unix()
403+
wantEnd := time.Date(2026, 8, 11, 10, 5, 0, 0, time.UTC).Unix()
404+
if start != strconv.FormatInt(wantStart, 10) || end != strconv.FormatInt(wantEnd, 10) {
405+
t.Errorf("expected start=%d end=%d, got start=%s end=%s", wantStart, wantEnd, start, end)
406+
}
407+
}
408+
293409
func TestMonitQueryRowsInvalidArgs(t *testing.T) {
294410
saveAndResetGlobals(t)
295411
stub := newGFStub(t)

internal/timeutil/parse.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import (
1717
// - Date: "2026-04-01" (parsed as local midnight)
1818
// - Datetime: "2026-04-01 10:00:00" or "2026-04-01T10:00:00" (parsed as local time)
1919
// - RFC3339 with timezone: "2026-04-01T10:00:00+08:00" / "...Z" (the format the SDK emits)
20-
// - Unix timestamp: "1712000000" (passed through)
20+
// - Unix timestamp in seconds: "1712000000" (passed through)
21+
// - Unix timestamp in milliseconds: "1712000000000" (divided down to seconds;
22+
// distinguished from seconds by magnitude — see msThreshold below)
2123
func Parse(s string) (int64, error) {
2224
s = strings.TrimSpace(s)
2325
if s == "" || s == "now" {
@@ -67,14 +69,26 @@ func Parse(s string) (int64, error) {
6769
}
6870
}
6971

70-
// Try unix timestamp
71-
if ts, err := strconv.ParseInt(s, 10, 64); err == nil && ts > 1000000000 {
72-
return ts, nil
72+
// Try unix timestamp, seconds or milliseconds. A value at or above
73+
// msThreshold (100 billion) would be seconds only for a date past the
74+
// year 5138, so any realistic timestamp that large must be milliseconds.
75+
if ts, err := strconv.ParseInt(s, 10, 64); err == nil {
76+
switch {
77+
case ts >= msThreshold:
78+
return ts / 1000, nil
79+
case ts > 1000000000:
80+
return ts, nil
81+
}
7382
}
7483

75-
return 0, fmt.Errorf("unable to parse time %q: expected duration (24h), RFC3339 (2006-01-02T15:04:05Z07:00), date (2006-01-02), datetime (2006-01-02 15:04:05), or unix timestamp", s)
84+
return 0, fmt.Errorf("unable to parse time %q: expected duration (24h), RFC3339 (2006-01-02T15:04:05Z07:00), date (2006-01-02), datetime (2006-01-02 15:04:05), or unix timestamp in seconds or milliseconds", s)
7685
}
7786

87+
// msThreshold is the magnitude cutoff separating a unix timestamp in seconds
88+
// from one in milliseconds: 100,000,000,000 as seconds is the year 5138, so
89+
// any input at or above it is treated as milliseconds instead.
90+
const msThreshold = 100_000_000_000
91+
7892
// expandDays converts day shorthand (e.g. "7d", "30d") to hours for time.ParseDuration.
7993
// If the string does not end with "d" or is not purely numeric before it, returns as-is.
8094
func expandDays(s string) string {

internal/timeutil/parse_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,29 @@ func TestParse(t *testing.T) {
194194
wantExact: time.Date(2026, 5, 29, 14, 0, 0, 0, time.Local).Unix(),
195195
exactMatch: true,
196196
},
197+
// 25. Unix timestamp in milliseconds (13 digits) → divided down to seconds
198+
{
199+
name: "unix milliseconds 1712000000000",
200+
input: "1712000000000",
201+
wantExact: 1712000000,
202+
exactMatch: true,
203+
},
204+
// 26. Boundary at the seconds/milliseconds threshold: just below is
205+
// still treated as seconds, however implausible a date that is.
206+
{
207+
name: "boundary below ms threshold treated as seconds",
208+
input: "99999999999",
209+
wantExact: 99999999999,
210+
exactMatch: true,
211+
},
212+
// 27. Boundary at the seconds/milliseconds threshold: at or above is
213+
// treated as milliseconds.
214+
{
215+
name: "boundary at ms threshold treated as milliseconds",
216+
input: "100000000000",
217+
wantExact: 100000000000 / 1000,
218+
exactMatch: true,
219+
},
197220
}
198221

199222
for _, tc := range tests {

0 commit comments

Comments
 (0)