Files
traces/cmd/tempo-cli/shared.go
Suraj Nathandzeekay f13aa343a8 tempo-cli: support relative time strings for start/end args (#6458)
* tempo-cli: support relative time strings (now, now-1h) for start/end args

Add support for relative time strings across all CLI commands that
accept start/end arguments, matching Grafana's time string format.

example: now-1h, now, now-30m, etc.

Affected commands:
- query api search
- query api search-tags
- query api search-tag-values
- query api metrics query-range
- query search

Help text previously said "ISO8601 format" or showed a ISO 8601 format but the code was parsing with
`time.RFC3339` (2006-01-02T15:04:05Z07:00). ISO 8601 is a broad standard that allows many representations
including timestamps without timezone offsets.

RFC 3339 is a strict profile of ISO 8601 that always requires a timezone offset (Z or +HH:MM).

Help text is update to say RFC3339 with an example.

We now accept a uniform time format across all commands: either RFC3339 absolute timestamps or relative time strings.

query search: previously accepted timestamps without timezone (2024-03-15T10:30:00) using the layout "2006-01-02T15:04:05".
This is valid ISO 8601 but not valid RFC 3339. These timestamps are now rejected.
Users must include a timezone offset (e.g. 2024-03-15T10:30:00Z) or use relative time (e.g. now-1h).

* update docs

* Add CHANGELOG.md

* trim spaces before parsing and add tests
2026-02-22 00:55:24 -08:00

206 lines
4.7 KiB
Go

package main
import (
"context"
"crypto/x509"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/gogo/protobuf/jsonpb"
"github.com/gogo/protobuf/proto"
"github.com/google/uuid"
"github.com/prometheus/common/model"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"github.com/grafana/tempo/pkg/boundedwaitgroup"
"github.com/grafana/tempo/tempodb/backend"
)
type unifiedBlockMeta struct {
backend.BlockMeta
backend.CompactedBlockMeta
window int64
compacted bool
}
func getMeta(meta *backend.BlockMeta, compactedMeta *backend.CompactedBlockMeta, windowRange time.Duration) unifiedBlockMeta {
if meta != nil {
return unifiedBlockMeta{
BlockMeta: *meta,
window: meta.EndTime.Unix() / int64(windowRange/time.Second),
compacted: false,
}
}
if compactedMeta != nil {
return unifiedBlockMeta{
BlockMeta: compactedMeta.BlockMeta,
CompactedBlockMeta: *compactedMeta,
window: compactedMeta.EndTime.Unix() / int64(windowRange/time.Second),
compacted: true,
}
}
return unifiedBlockMeta{
BlockMeta: backend.BlockMeta{
BlockID: backend.UUID{},
CompactionLevel: 0,
TotalObjects: -1,
},
window: -1,
compacted: false,
}
}
type blockStats struct {
unifiedBlockMeta
}
func loadBucket(r backend.Reader, c backend.Compactor, tenantID string, windowRange time.Duration, includeCompacted bool) ([]blockStats, error) {
blockIDs, compactedBlockIDs, err := r.Blocks(context.Background(), tenantID)
if err != nil {
return nil, err
}
blockIDs = append(blockIDs, compactedBlockIDs...)
fmt.Println("total blocks: ", len(blockIDs))
// Load in parallel
wg := boundedwaitgroup.New(20)
resultsCh := make(chan blockStats, len(blockIDs))
for blockNum, id := range blockIDs {
wg.Add(1)
go func(id2 backend.UUID, blockNum2 int) {
defer wg.Done()
b, err := loadBlock(r, c, tenantID, id2, blockNum2, windowRange, includeCompacted)
if err != nil {
fmt.Println("Error loading block:", id2, err)
return
}
if b != nil {
resultsCh <- *b
}
}(backend.UUID(id), blockNum)
}
wg.Wait()
close(resultsCh)
results := make([]blockStats, 0)
for b := range resultsCh {
results = append(results, b)
}
sort.Slice(results, func(i, j int) bool {
return results[i].EndTime.Before(results[j].EndTime)
})
return results, nil
}
func loadBlock(r backend.Reader, c backend.Compactor, tenantID string, id backend.UUID, blockNum int, windowRange time.Duration, includeCompacted bool) (*blockStats, error) {
fmt.Print(".")
if blockNum%100 == 0 {
fmt.Print(strconv.Itoa(blockNum))
}
meta, err := r.BlockMeta(context.Background(), (uuid.UUID)(id), tenantID)
if errors.Is(err, backend.ErrDoesNotExist) && !includeCompacted {
return nil, nil
} else if err != nil && !errors.Is(err, backend.ErrDoesNotExist) {
return nil, err
}
compactedMeta, err := c.CompactedBlockMeta((uuid.UUID)(id), tenantID)
if err != nil && !errors.Is(err, backend.ErrDoesNotExist) {
return nil, err
}
return &blockStats{
unifiedBlockMeta: getMeta(meta, compactedMeta, windowRange),
}, nil
}
func printAsJSON(pb proto.Message) error {
m := jsonpb.Marshaler{}
traceJSON, err := m.MarshalToString(pb)
if err != nil {
return err
}
fmt.Println(string(traceJSON))
return nil
}
func httpScheme(secure bool) string {
if secure {
return "https"
}
return "http"
}
func grpcTransportCredentials(secure bool) (opt grpc.DialOption, err error) {
var creds credentials.TransportCredentials
if secure {
certPool, err := x509.SystemCertPool()
if err != nil {
return nil, err
}
creds = credentials.NewClientTLSFromCert(certPool, "")
} else {
creds = insecure.NewCredentials()
}
return grpc.WithTransportCredentials(creds), nil
}
// parseTime parses a time string that can be:
// - relative: "now", "now-1h", "now-30m", "now-3h30m"
// - RFC3339: "2024-01-01T00:00:00Z"
func parseTime(s string) (time.Time, error) {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "now") {
return parseRelativeTime(s)
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
return time.Time{}, fmt.Errorf("failed to parse time: %q use relative (now, now-1h) or absolute RFC3339 (2006-01-02T15:04:05Z07:00) format", s)
}
func parseRelativeTime(s string) (time.Time, error) {
now := time.Now()
s = strings.TrimSpace(s)
if s == "now" {
return now, nil
}
// expect "now-<duration>"
if !strings.HasPrefix(s, "now-") {
return time.Time{}, fmt.Errorf("invalid relative time %q: expected format like now-1h", s)
}
durStr := s[len("now-"):]
d, err := model.ParseDuration(durStr)
if err != nil {
return time.Time{}, fmt.Errorf("failed to parse duration %q: %w", durStr, err)
}
return now.Add(-time.Duration(d)), nil
}