diff --git a/api/prometheus/v1/api.go b/api/prometheus/v1/api.go index d7b5e8b5a..d43738b10 100644 --- a/api/prometheus/v1/api.go +++ b/api/prometheus/v1/api.go @@ -1575,6 +1575,50 @@ func (h *apiClientImpl) DoGetFallback(ctx context.Context, u *url.URL, args url. return resp, body, warnings, err } +var ( + // Prometheus's HTTP API accepts these boundary timestamps explicitly even + // though Go's RFC3339 parser cannot round-trip years with more than four + // digits. Keep these values aligned with the server-side defaults so very + // low/high sentinels like model.Earliest and model.Latest map to something + // the API can parse without overflowing. + minTime = time.Unix(math.MinInt64/1000+62135596801, 0).UTC() + maxTime = time.Unix(math.MaxInt64/1000-62135596801, 999999999).UTC() + + minTimeFormatted = minTime.Format(time.RFC3339Nano) + maxTimeFormatted = maxTime.Format(time.RFC3339Nano) +) + func formatTime(t time.Time) string { - return strconv.FormatFloat(float64(t.Unix())+float64(t.Nanosecond())/1e9, 'f', -1, 64) + switch { + case !t.After(minTime): + return minTimeFormatted + case !t.Before(maxTime): + return maxTimeFormatted + } + + // Avoid t.UnixNano here: it is undefined for times that cannot be + // represented as an int64 nanosecond offset even though time.Unix and + // Nanosecond still expose the second/nanosecond components we need. + return formatTimeString(t.Unix(), t.Nanosecond()) +} + +func formatTimeString(sec int64, nsec int) string { + if sec >= 0 { + return formatTimeParts("", uint64(sec), nsec) + } + if nsec == 0 { + return strconv.FormatInt(sec, 10) + } + + return formatTimeParts("-", uint64(-(sec + 1)), int(time.Second)-nsec) +} + +func formatTimeParts(sign string, sec uint64, nsec int) string { + s := sign + strconv.FormatUint(sec, 10) + if nsec == 0 { + return s + } + + fraction := strconv.Itoa(nsec + int(time.Second))[1:] + return s + "." + strings.TrimRight(fraction, "0") } diff --git a/api/prometheus/v1/api_test.go b/api/prometheus/v1/api_test.go index f846bd9b5..1c5e7b8fc 100644 --- a/api/prometheus/v1/api_test.go +++ b/api/prometheus/v1/api_test.go @@ -1613,6 +1613,43 @@ func TestAPIClientDo(t *testing.T) { } } +func TestFormatTimeUsesModelTimestampEncoding(t *testing.T) { + tests := []struct { + name string + ts time.Time + want string + }{ + { + name: "regular time keeps millisecond precision", + ts: time.Unix(1700000000, 123456789).UTC(), + want: "1700000000.123456789", + }, + { + name: "negative fractional times keep the correct sign and fraction", + ts: time.Unix(-1, 100000000).UTC(), + want: "-0.9", + }, + { + name: "model earliest clamps to prometheus parseable boundary", + ts: model.Earliest.Time(), + want: minTimeFormatted, + }, + { + name: "model latest clamps to prometheus parseable boundary", + ts: model.Latest.Time(), + want: maxTimeFormatted, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatTime(tt.ts); got != tt.want { + t.Fatalf("formatTime() = %q, want %q", got, tt.want) + } + }) + } +} + func TestSamplesJSONSerialization(t *testing.T) { tests := []struct { point model.SamplePair