Files
traces/cmd/tempo-cli/cmd-query-search.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

149 lines
3.4 KiB
Go

package main
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"path"
"google.golang.org/grpc"
"github.com/gogo/protobuf/jsonpb"
"github.com/grafana/dskit/user"
"github.com/grafana/tempo/pkg/api"
"github.com/grafana/tempo/pkg/tempopb"
)
type querySearchCmd struct {
HostPort string `arg:"" help:"tempo host and port. scheme and path will be provided based on query type. e.g. localhost:3200"`
TraceQL string `arg:"" optional:"" help:"traceql query"`
Start string `arg:"" optional:"" help:"start time in RFC3339 (e.g. 2006-01-02T15:04:05Z07:00) or relative (e.g. now-1h) format"`
End string `arg:"" optional:"" help:"end time in RFC3339 (e.g. 2006-01-02T15:04:05Z07:00) or relative (e.g. now) format"`
OrgID string `help:"optional orgID"`
UseGRPC bool `help:"stream search results over GRPC"`
SPSS int `help:"spans per spanset" default:"0"`
Limit int `help:"limit number of results" default:"0"`
PathPrefix string `help:"string to prefix all http paths with"`
Secure bool `help:"use https or grpc with TLS"`
}
func (cmd *querySearchCmd) Run(_ *globalOptions) error {
startDate, err := parseTime(cmd.Start)
if err != nil {
return err
}
start := startDate.Unix()
endDate, err := parseTime(cmd.End)
if err != nil {
return err
}
end := endDate.Unix()
req := &tempopb.SearchRequest{
Query: cmd.TraceQL,
Start: uint32(start),
End: uint32(end),
SpansPerSpanSet: uint32(cmd.SPSS),
Limit: uint32(cmd.Limit),
}
if cmd.UseGRPC {
return cmd.searchGRPC(req)
}
return cmd.searchHTTP(req)
}
func (cmd *querySearchCmd) searchGRPC(req *tempopb.SearchRequest) error {
ctx := user.InjectOrgID(context.Background(), cmd.OrgID)
ctx, err := user.InjectIntoGRPCRequest(ctx)
if err != nil {
return err
}
creds, err := grpcTransportCredentials(cmd.Secure)
if err != nil {
return err
}
clientConn, err := grpc.NewClient(cmd.HostPort, creds)
if err != nil {
return err
}
client := tempopb.NewStreamingQuerierClient(clientConn)
resp, err := client.Search(ctx, req)
if err != nil {
return err
}
for {
searchResp, err := resp.Recv()
if searchResp != nil {
err = printAsJSON(searchResp)
if err != nil {
return err
}
}
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return err
}
}
}
// nolint: goconst // goconst wants us to make http:// a const
func (cmd *querySearchCmd) searchHTTP(req *tempopb.SearchRequest) error {
httpReq, err := http.NewRequest("GET", httpScheme(cmd.Secure)+"://"+path.Join(cmd.HostPort, cmd.PathPrefix, api.PathSearch), nil)
if err != nil {
return err
}
httpReq, err = api.BuildSearchRequest(httpReq, req)
if err != nil {
return err
}
httpReq.Header = http.Header{}
err = user.InjectOrgIDIntoHTTPRequest(user.InjectOrgID(context.Background(), cmd.OrgID), httpReq)
if err != nil {
return err
}
// fmt.Println(httpReq)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return err
}
defer httpResp.Body.Close()
body, err := io.ReadAll(httpResp.Body)
if err != nil {
return err
}
if httpResp.StatusCode != http.StatusOK {
return errors.New("failed to query. body: " + string(body) + " status: " + httpResp.Status)
}
resp := &tempopb.SearchResponse{}
err = jsonpb.Unmarshal(bytes.NewReader(body), resp)
if err != nil {
panic("failed to parse resp: " + err.Error())
}
err = printAsJSON(resp)
if err != nil {
return err
}
return nil
}