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
16 changes: 8 additions & 8 deletions cmd/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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,
},
Expand All @@ -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)
}
Expand Down
76 changes: 76 additions & 0 deletions cmd/metrics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
7 changes: 5 additions & 2 deletions pkg/util/time.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}