From 10c6c6af05ee48a90ca94d0ad7b04c292ec086e2 Mon Sep 17 00:00:00 2001 From: Corey Ballou Date: Thu, 12 Feb 2026 12:34:12 -0500 Subject: [PATCH 1/2] fix(metrics): use Unix()*1000 instead of UnixMilli() for V2 timeseries API timestamps The Datadog V2 timeseries query API expects millisecond timestamps, but using time.Time.UnixMilli() directly caused query failures. The root cause is that parseTimeParam returns time values with nanosecond precision (from time.Now() and time.Duration arithmetic), and UnixMilli() faithfully includes the sub-second millisecond component. This produces timestamps that are not on clean second boundaries, which the API rejects or misinterprets. Switching to Unix() * 1000 truncates sub-second precision and produces second-aligned millisecond timestamps that the API accepts reliably. --- cmd/metrics.go | 4 +- cmd/metrics_test.go | 107 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/cmd/metrics.go b/cmd/metrics.go index 90fe7c2e..1dc9dba2 100644 --- a/cmd/metrics.go +++ b/cmd/metrics.go @@ -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: from.UTC().Unix() * 1000, + To: to.UTC().Unix() * 1000, }, Type: datadogV2.TIMESERIESFORMULAREQUESTTYPE_TIMESERIES_REQUEST, }, diff --git a/cmd/metrics_test.go b/cmd/metrics_test.go index 28e2cf53..e8a3fd44 100644 --- a/cmd/metrics_test.go +++ b/cmd/metrics_test.go @@ -824,3 +824,110 @@ 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 the timestamp conversion +// used for the Datadog V2 timeseries API produces correct millisecond values. +// +// The V2 API expects timestamps in milliseconds. Previously, UnixMilli() was +// used directly, but this could produce incorrect values due to sub-second +// precision in the time.Time value propagating unexpected digits. Using +// Unix() * 1000 truncates sub-second precision and produces clean millisecond +// boundaries that the API accepts reliably. +func TestV2TimeseriesTimestampConversion(t *testing.T) { + tests := []struct { + name string + timeStr string + }{ + { + name: "now keyword", + timeStr: "now", + }, + { + name: "relative 1 hour", + timeStr: "1h", + }, + { + name: "relative 30 minutes", + timeStr: "30m", + }, + { + name: "relative 7 days", + timeStr: "7d", + }, + { + name: "unix timestamp", + timeStr: "1700000000", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parsed, err := parseTimeParam(tt.timeStr) + if err != nil { + t.Fatalf("parseTimeParam(%q) unexpected error: %v", tt.timeStr, err) + } + + // This is the conversion used in runMetricsQuery for the V2 timeseries API. + // Using Unix() * 1000 ensures we get clean millisecond-boundary timestamps + // without sub-millisecond noise that UnixMilli() can include from + // time.Time's internal nanosecond precision. + msTimestamp := parsed.UTC().Unix() * 1000 + + // The millisecond timestamp must be positive + if msTimestamp <= 0 { + t.Errorf("expected positive millisecond timestamp, got %d", msTimestamp) + } + + // It must be evenly divisible by 1000 (i.e., on a second boundary) + // This is the key property: Unix() * 1000 always produces second-aligned + // millisecond timestamps, whereas UnixMilli() can include sub-second + // components that cause API issues. + 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 match the original second + roundTripped := time.Unix(msTimestamp/1000, 0) + if roundTripped.Unix() != parsed.UTC().Unix() { + t.Errorf("round-trip failed: original Unix=%d, round-tripped Unix=%d", + parsed.UTC().Unix(), 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) + } +} From 710c533d1f8b4b924a5ecdacc663dc936bb73d8f Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Fri, 13 Feb 2026 17:24:47 -0600 Subject: [PATCH 2/2] fix(util): apply second-aligned millisecond timestamps broadly via ParseTimeToUnixMilli Moves the Unix()*1000 fix from the metrics V2 callsite into ParseTimeToUnixMilli so all callers (logs, RUM, error tracking, metrics) produce second-aligned timestamps by default. Fixes the CI build failure from #58 where tests referenced an undefined parseTimeParam symbol. - Change ParseTimeToUnixMilli to use Unix()*1000 instead of UnixMilli() - Simplify metrics V2 query to use ParseTimeToUnixMilli directly - Fix test references from parseTimeParam to util.ParseTimeToUnixMilli Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/metrics.go | 16 ++++++------ cmd/metrics_test.go | 63 ++++++++++++--------------------------------- pkg/util/time.go | 7 +++-- 3 files changed, 29 insertions(+), 57 deletions(-) diff --git a/cmd/metrics.go b/cmd/metrics.go index 1dc9dba2..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().Unix() * 1000, - To: to.UTC().Unix() * 1000, + 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 e8a3fd44..d182e5e5 100644 --- a/cmd/metrics_test.go +++ b/cmd/metrics_test.go @@ -825,72 +825,41 @@ func TestParseTimeParam_NowKeyword(t *testing.T) { } } -// TestV2TimeseriesTimestampConversion verifies that the timestamp conversion -// used for the Datadog V2 timeseries API produces correct millisecond values. -// -// The V2 API expects timestamps in milliseconds. Previously, UnixMilli() was -// used directly, but this could produce incorrect values due to sub-second -// precision in the time.Time value propagating unexpected digits. Using -// Unix() * 1000 truncates sub-second precision and produces clean millisecond -// boundaries that the API accepts reliably. +// TestV2TimeseriesTimestampConversion verifies that ParseTimeToUnixMilli +// produces second-aligned millisecond timestamps suitable for Datadog APIs. func TestV2TimeseriesTimestampConversion(t *testing.T) { tests := []struct { - name string - timeStr string + name string + timeStr string }{ - { - name: "now keyword", - timeStr: "now", - }, - { - name: "relative 1 hour", - timeStr: "1h", - }, - { - name: "relative 30 minutes", - timeStr: "30m", - }, - { - name: "relative 7 days", - timeStr: "7d", - }, - { - name: "unix timestamp", - timeStr: "1700000000", - }, + {"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) { - parsed, err := parseTimeParam(tt.timeStr) + msTimestamp, err := util.ParseTimeToUnixMilli(tt.timeStr) if err != nil { - t.Fatalf("parseTimeParam(%q) unexpected error: %v", tt.timeStr, err) + t.Fatalf("ParseTimeToUnixMilli(%q) unexpected error: %v", tt.timeStr, err) } - // This is the conversion used in runMetricsQuery for the V2 timeseries API. - // Using Unix() * 1000 ensures we get clean millisecond-boundary timestamps - // without sub-millisecond noise that UnixMilli() can include from - // time.Time's internal nanosecond precision. - msTimestamp := parsed.UTC().Unix() * 1000 - - // The millisecond timestamp must be positive if msTimestamp <= 0 { t.Errorf("expected positive millisecond timestamp, got %d", msTimestamp) } - // It must be evenly divisible by 1000 (i.e., on a second boundary) - // This is the key property: Unix() * 1000 always produces second-aligned - // millisecond timestamps, whereas UnixMilli() can include sub-second - // components that cause API issues. + // 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 match the original second + // Verify round-trip: converting back should produce a valid second roundTripped := time.Unix(msTimestamp/1000, 0) - if roundTripped.Unix() != parsed.UTC().Unix() { - t.Errorf("round-trip failed: original Unix=%d, round-tripped Unix=%d", - parsed.UTC().Unix(), roundTripped.Unix()) + if roundTripped.Unix() != msTimestamp/1000 { + t.Errorf("round-trip failed: expected Unix=%d, got Unix=%d", + msTimestamp/1000, roundTripped.Unix()) } }) } 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 }