diff --git a/cmd/metrics.go b/cmd/metrics.go index 90fe7c2e..df419333 100644 --- a/cmd/metrics.go +++ b/cmd/metrics.go @@ -533,13 +533,13 @@ func runMetricsQuery(cmd *cobra.Command, args []string) error { return err } - // Parse time ranges - from, err := util.ParseTimeParam(fromTime) + // Parse time ranges as second-aligned millisecond timestamps + fromMs, err := util.ParseTimeToUnixMilli(fromTime) if err != nil { return fmt.Errorf("invalid --from time: %w", err) } - to, err := util.ParseTimeParam(toTime) + toMs, err := util.ParseTimeToUnixMilli(toTime) if err != nil { return fmt.Errorf("invalid --to time: %w", err) } @@ -560,8 +560,8 @@ func runMetricsQuery(cmd *cobra.Command, args []string) error { Name: datadog.PtrString("a"), }, }}, - From: from.UTC().UnixMilli(), - To: to.UTC().UnixMilli(), + From: fromMs, + To: toMs, }, Type: datadogV2.TIMESERIESFORMULAREQUESTTYPE_TIMESERIES_REQUEST, }, @@ -572,11 +572,11 @@ func runMetricsQuery(cmd *cobra.Command, args []string) error { if r != nil { apiBody := extractAPIErrorBody(err) if apiBody != "" { - return fmt.Errorf("failed to query metrics: %w\nStatus: %d\nAPI Response: %s\n\nRequest Details:\n- Query: %s\n- From: %s (Unix: %d)\n- To: %s (Unix: %d)\n\nTroubleshooting:\n- Verify your query syntax is correct (e.g., avg:metric.name{filter})\n- Check that the time range is valid\n- Ensure the metric exists and has data in the specified time range\n- Confirm you have proper permissions to access the metric", + return fmt.Errorf("failed to query metrics: %w\nStatus: %d\nAPI Response: %s\n\nRequest Details:\n- Query: %s\n- From: %d\n- To: %d\n\nTroubleshooting:\n- Verify your query syntax is correct (e.g., avg:metric.name{filter})\n- Check that the time range is valid\n- Ensure the metric exists and has data in the specified time range\n- Confirm you have proper permissions to access the metric", err, r.StatusCode, apiBody, queryString, - from.Format(time.RFC3339), from.Unix(), - to.Format(time.RFC3339), to.Unix()) + fromMs, + toMs) } return fmt.Errorf("failed to query metrics: %w (status: %d)", err, r.StatusCode) } diff --git a/cmd/metrics_test.go b/cmd/metrics_test.go index 28e2cf53..d182e5e5 100644 --- a/cmd/metrics_test.go +++ b/cmd/metrics_test.go @@ -824,3 +824,79 @@ func TestParseTimeParam_NowKeyword(t *testing.T) { t.Errorf("util.ParseTimeParam(\"now\") = %v, too far from current time %v (diff: %v)", result, now, diff) } } + +// TestV2TimeseriesTimestampConversion verifies that ParseTimeToUnixMilli +// produces second-aligned millisecond timestamps suitable for Datadog APIs. +func TestV2TimeseriesTimestampConversion(t *testing.T) { + tests := []struct { + name string + timeStr string + }{ + {"now keyword", "now"}, + {"relative 1 hour", "1h"}, + {"relative 30 minutes", "30m"}, + {"relative 7 days", "7d"}, + {"unix timestamp", "1700000000"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msTimestamp, err := util.ParseTimeToUnixMilli(tt.timeStr) + if err != nil { + t.Fatalf("ParseTimeToUnixMilli(%q) unexpected error: %v", tt.timeStr, err) + } + + if msTimestamp <= 0 { + t.Errorf("expected positive millisecond timestamp, got %d", msTimestamp) + } + + // Must be on a second boundary (divisible by 1000) + if msTimestamp%1000 != 0 { + t.Errorf("timestamp %d is not on a second boundary (remainder: %d)", msTimestamp, msTimestamp%1000) + } + + // Verify round-trip: converting back should produce a valid second + roundTripped := time.Unix(msTimestamp/1000, 0) + if roundTripped.Unix() != msTimestamp/1000 { + t.Errorf("round-trip failed: expected Unix=%d, got Unix=%d", + msTimestamp/1000, roundTripped.Unix()) + } + }) + } +} + +// TestV2TimestampUnixMilliVsUnixTimes1000 demonstrates the difference between +// UnixMilli() and Unix()*1000 when a time.Time has sub-second precision. +// parseTimeParam("now") calls time.Now() which includes nanosecond precision, +// and relative times are computed via time.Duration arithmetic that also +// preserves nanosecond precision. +func TestV2TimestampUnixMilliVsUnixTimes1000(t *testing.T) { + // Construct a time with known sub-second precision + // 2024-01-15 12:00:00.123456789 UTC + ts := time.Date(2024, 1, 15, 12, 0, 0, 123456789, time.UTC) + + unixMilli := ts.UnixMilli() // Includes millisecond component: ...123 + unixTimes1000 := ts.Unix() * 1000 // Truncated to second boundary: ...000 + + // UnixMilli includes the sub-second milliseconds + if unixMilli%1000 == 0 { + t.Error("expected UnixMilli() to have sub-second component for a time with nanoseconds") + } + + // Unix()*1000 is always on a second boundary + if unixTimes1000%1000 != 0 { + t.Errorf("expected Unix()*1000 to be on second boundary, got remainder %d", unixTimes1000%1000) + } + + // The difference should be exactly the millisecond component (123ms) + diff := unixMilli - unixTimes1000 + if diff != 123 { + t.Errorf("expected difference of 123ms, got %d", diff) + } + + // Both should represent approximately the same point in time + // (within 1 second) + if diff < 0 || diff >= 1000 { + t.Errorf("timestamps diverged by more than 1 second: diff=%dms", diff) + } +} diff --git a/pkg/util/time.go b/pkg/util/time.go index 7b2b9d14..627191e8 100644 --- a/pkg/util/time.go +++ b/pkg/util/time.go @@ -89,11 +89,14 @@ func ParseTimeToUnix(timeStr string) (int64, error) { return t.Unix(), nil } -// ParseTimeToUnixMilli parses time string and returns Unix timestamp in milliseconds +// ParseTimeToUnixMilli parses time string and returns Unix timestamp in milliseconds. +// Uses Unix()*1000 instead of UnixMilli() to produce second-aligned timestamps. +// time.Now() and duration arithmetic produce nanosecond precision, and UnixMilli() +// preserves the sub-second component which some Datadog APIs reject or misinterpret. func ParseTimeToUnixMilli(timeStr string) (int64, error) { t, err := ParseTimeParam(timeStr) if err != nil { return 0, err } - return t.UnixMilli(), nil + return t.Unix() * 1000, nil }