Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ If you were at `firehose-core` version `1.0.0` and are bumping to `1.1.0`, you s

- Flags inherited from an intermediate command are now printed in their own help section instead of being mixed into `Global Flags`, so `fire{chain} tools ...` commands show a `Tools Flags` section with `--output`, `--bytes-encoding`, `--proto-paths` and `--merged-blocks-bundle-size`.

- `firecore tools substreams logs connection` now takes its time range via `--since`/`--date-range` flags instead of a positional `[<date-range>]` argument, matching `logs connections`. Both commands now share the same `--since`/`--date-range` parsing: `--since` accepts a relative duration (`30m`, `2h`, `1d`, `1w`, `"1 day ago"`, …) instead of Go's native duration format, which silently rejected `d`/`w` units, and `--date-range` accepts the colon-separated and bare-timestamp forms already supported by `logs reexec`'s date-range argument. `logs reexec` also gained the `1w` shortcut. A value can now be copied between `logs connections` and `logs connection` unchanged.

### Deprecated

- `firecore.HideGlobalFlagsOnChildCmd` is now a no-op and prints a deprecation warning when called, remove the call from your chain. Global flags are all hidden behind `--gh` now, hiding a hand-picked subset of them is no longer useful.
Expand Down
7 changes: 6 additions & 1 deletion cmd/tools/substreams/daterange.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ func ParseLogDateRange(args []string) (time.Time, time.Time, error) {

var relDurationRegex = regexp.MustCompile(`(?i)^\s*(\d+)\s*([a-z]+?)(?:\s+ago)?\s*$`)

// parseRelativeDuration parses strings like "1d", "2hr", "30m", "1 day ago".
// parseRelativeDuration parses strings like "1d", "2hr", "30m", "1w", "1 day ago".
//
// To avoid calendar ambiguity (months/DST/etc.), units are fixed-length
// shortcuts: d == 24h, w == 7d == 168h.
func parseRelativeDuration(s string) (time.Duration, bool) {
m := relDurationRegex.FindStringSubmatch(strings.TrimSpace(s))
if m == nil {
Expand All @@ -43,6 +46,8 @@ func parseRelativeDuration(s string) (time.Duration, bool) {
}
unit := strings.ToLower(m[2])
switch {
case strings.HasPrefix(unit, "w"):
return time.Duration(n) * 7 * 24 * time.Hour, true
case strings.HasPrefix(unit, "d"):
return time.Duration(n) * 24 * time.Hour, true
case strings.HasPrefix(unit, "h"):
Expand Down
2 changes: 2 additions & 0 deletions cmd/tools/substreams/daterange_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ func TestParseRelativeDuration(t *testing.T) {
{"1d", 24 * time.Hour, true},
{"2hr", 2 * time.Hour, true},
{"30m", 30 * time.Minute, true},
{"1w", 7 * 24 * time.Hour, true},
{"2w", 14 * 24 * time.Hour, true},
{"1 day ago", 24 * time.Hour, true},
{"2 hours ago", 2 * time.Hour, true},
{"0d", 0, false}, // zero is rejected
Expand Down
79 changes: 30 additions & 49 deletions cmd/tools/substreams/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ Note: Currently only GCP Cloud Logging backend is supported.`,
return cmd
}

const (
sinceFlagHelp = "Look back duration: relative value like '30m', '2h', '1d', '1w', or \"1 day ago\". Mutually exclusive with --date-range"
dateRangeFlagHelp = "Date range as '<start>/<end>', '<start>:<end>', or a single '<start>' (end defaults to now). Mutually exclusive with --since"
)

// NewToolsLogsConnectionsCmd returns the "connections" subcommand
func NewToolsLogsConnectionsCmd(logger *zap.Logger) *cobra.Command {
cmd := &cobra.Command{
Expand All @@ -47,8 +52,10 @@ Note: Currently only GCP Cloud Logging backend is supported.`,
Example: ` # Show connections for the last hour (default)
firecore tools substreams logs connections sfinfra --gcp-project my-project

# Show connections for the last 30 minutes
# Show connections for the last 30 minutes, 2 days, or 1 week
firecore tools substreams logs connections sfinfra --gcp-project my-project --since 30m
firecore tools substreams logs connections sfinfra --gcp-project my-project --since 2d
firecore tools substreams logs connections sfinfra --gcp-project my-project --since 1w

# Show connections for a specific date range
firecore tools substreams logs connections sfinfra --gcp-project my-project --date-range 2024-01-15T10:00:00Z/2024-01-15T12:00:00Z
Expand All @@ -62,8 +69,8 @@ Note: Currently only GCP Cloud Logging backend is supported.`,
}

cmd.Flags().String("backend", "gcp", "Log backend to use (currently only 'gcp' supported)")
cmd.Flags().Duration("since", time.Duration(0), "Look back duration (e.g., '1h', '30m', '2d'). Mutually exclusive with --date-range")
cmd.Flags().String("date-range", "", "Date range in format '<start>[/<end>]'. End defaults to now. Mutually exclusive with --since")
cmd.Flags().String("since", "", sinceFlagHelp)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's weird with those two flags possibly doing the same thing..

functionally identical:

--since=1h
--date-range=1h

maybe we should have just --since= and --until= ?
like:

--since=24h and --until=1h
--since=2026-05-21T10:23:12Z and --until=2026-05-22T10:23:12Z

We could also accept --since=24h and --until=+2h30m

So there wouldn't be overlap between the function of both flags.

Either you use just --since with a duration (most common case for "last x hours")
Either you use both with durations --since=48h --until=24h (sous-entendu: "ago")
Either you use timestamps
Either you use timestamp for since, and use an until that is relative to the since (with + prefix)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping just --since then, let's keep it simple

cmd.Flags().String("date-range", "", dateRangeFlagHelp)
cmd.Flags().StringP("k8s-namespace", "n", "", "Kubernetes namespace to filter logs")
cmd.Flags().String("gcp-project", "", "GCP project ID (required for GCP backend)")

Expand All @@ -72,27 +79,27 @@ Note: Currently only GCP Cloud Logging backend is supported.`,

func runConnections(ctx context.Context, userID string, cmd *cobra.Command, logger *zap.Logger) error {
backend := sflags.MustGetString(cmd, "backend")
since := sflags.MustGetDuration(cmd, "since")
since := sflags.MustGetString(cmd, "since")
dateRange := sflags.MustGetString(cmd, "date-range")
namespace := sflags.MustGetString(cmd, "k8s-namespace")
gcpProject := sflags.MustGetString(cmd, "gcp-project")

logger.Debug("connections command invoked",
zap.String("user_id", userID),
zap.String("backend", backend),
zap.Duration("since", since),
zap.String("since", since),
zap.String("date_range", dateRange),
zap.String("namespace", namespace),
zap.String("gcp_project", gcpProject),
)

// Validate flags
if since != 0 && dateRange != "" {
if since != "" && dateRange != "" {
return fmt.Errorf("--since and --date-range are mutually exclusive")
}

// Parse time range
startTime, endTime, err := parseTimeRange(since, dateRange)
startTime, endTime, err := parseTimeRange(since, dateRange, time.Hour)
if err != nil {
return fmt.Errorf("parsing time range: %w", err)
}
Expand Down Expand Up @@ -157,58 +164,32 @@ func runConnections(ctx context.Context, userID string, cmd *cobra.Command, logg
return nil
}

// parseTimeRange parses the --since or --date-range flags into start/end times
// If neither is provided, defaults to 1 hour ago
func parseTimeRange(since time.Duration, dateRange string) (time.Time, time.Time, error) {
// parseTimeRange parses the --since or --date-range flags into start/end times.
// Both are parsed by the same logic used by the 'reexec' date-range argument
// (ParseLogDateRange / parseRelativeDuration), so a value copied from one
// command works unchanged on the other. If neither is provided, looks back
// defaultLookback from now.
func parseTimeRange(since, dateRange string, defaultLookback time.Duration) (time.Time, time.Time, error) {
now := time.Now()

// Default: 1 hour ago
if since == 0 && dateRange == "" {
return now.Add(-1 * time.Hour), now, nil
if since == "" && dateRange == "" {
return now.Add(-defaultLookback), now, nil
}

if (since != 0) && dateRange != "" {
if since != "" && dateRange != "" {
return time.Time{}, time.Time{}, fmt.Errorf("--since and --date-range are mutually exclusive")
}

if since != 0 {
return now.Add(-since), now, nil
}

// Parse --date-range
return parseDateRange(dateRange)
}

// parseDateRange parses a date range in format "<start>[/<end>]"
// If end is omitted, uses current time
func parseDateRange(dateRange string) (time.Time, time.Time, error) {
// Split by '/' separator
parts := strings.SplitN(dateRange, "/", 2)

if len(parts) == 0 || parts[0] == "" {
return time.Time{}, time.Time{}, fmt.Errorf("invalid date range format")
}

startTime, err := parseDateTime(parts[0])
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("parsing start time: %w", err)
}

var endTime time.Time
if len(parts) == 1 || parts[1] == "" {
endTime = time.Now()
} else {
endTime, err = parseDateTime(parts[1])
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("parsing end time: %w", err)
if since != "" {
d, ok := parseRelativeDuration(since)
if !ok {
return time.Time{}, time.Time{}, fmt.Errorf("invalid --since value %q (try: 30m, 2h, 1d, 1w, \"1 day ago\")", since)
}
return now.Add(-d), now, nil
}

if endTime.Before(startTime) {
return time.Time{}, time.Time{}, fmt.Errorf("end time must be after start time")
}

return startTime, endTime, nil
// Parse --date-range, same format as the reexec date-range argument
return ParseLogDateRange([]string{dateRange})
}

// Supported date/time formats in order of precedence
Expand Down
40 changes: 13 additions & 27 deletions cmd/tools/substreams/logs_connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,19 @@ import (
// .spkg URL and the relevant Substreams request information for a single trace ID.
func NewToolsLogsConnectionCmd(logger *zap.Logger) *cobra.Command {
cmd := &cobra.Command{
Use: "connection <trace-id> [<date-range>]",
Use: "connection <trace-id>",
Short: "Show Substreams request details for a single trace ID",
Long: `Looks up the Substreams request matching the given trace ID in GCP Cloud Logging
and prints the request details (output module, blocks, mode flags, namespace), the .spkg URL
on the state store, and the request stats (when available).

The trace-id must be 0-32 characters.

The date-range is optional; when omitted it defaults to the last 7 days.

The date-range argument(s) accept various formats:

Relative: 1d 2hr 30m "1 day ago" "2 hours ago" "30 minutes ago"
Timestamp: 2024-01-15T10:00:00Z (past → search start to now)
Range: "2024-01-15T10:00:00Z:2024-01-15T12:00:00Z" (single arg with colon)
"2024-01-15T10:00:00Z/2024-01-15T12:00:00Z" (single arg with slash)
Two args: 2024-01-15T10:00:00Z 2024-01-15T12:00:00Z`,
When neither --since nor --date-range is provided, defaults to the last 7 days.`,
Example: ` firecore tools substreams logs connection bfb0980c436f3fd6f5564a31311d583f \
--gcp-project my-project

firecore tools substreams logs connection bfb0980c436f3fd6f5564a31311d583f 2h \
firecore tools substreams logs connection bfb0980c436f3fd6f5564a31311d583f --since 2h \
--state-store gs://my-bucket/substreams-states \
--gcp-project my-project

Expand All @@ -51,31 +43,32 @@ The date-range argument(s) accept various formats:
# Skip the 'Logs' section entirely, no question asked
firecore tools substreams logs connection bfb0980c436f3fd6f5564a31311d583f \
--gcp-project my-project --logs=false`,
Args: cobra.RangeArgs(1, 3),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runConnection(cmd.Context(), args, cmd, logger)
return runConnection(cmd.Context(), args[0], cmd, logger)
},
}

cmd.Flags().String("state-store", "", "State store URL where spkg files are stored (supports file:// and gs://). When set, the spkg is loaded to display the package name and full URL")
cmd.Flags().String("gcp-project", "", "GCP project ID used for log querying")
cmd.Flags().String("since", "", sinceFlagHelp)
cmd.Flags().String("date-range", "", dateRangeFlagHelp)
cmd.Flags().Bool("logs", false, "Print every log line of the request in a final 'Logs' section, explicitly passing --logs=false skips the section altogether")
cmd.Flags().Int("logs-limit", 500, "Maximum number of log lines to print with --logs, keeping the most recent ones (0 means no limit)")
cmd.MarkFlagRequired("gcp-project")

return cmd
}

func runConnection(ctx context.Context, args []string, cmd *cobra.Command, logger *zap.Logger) error {
traceID := args[0]
dateArgs := args[1:]

func runConnection(ctx context.Context, traceID string, cmd *cobra.Command, logger *zap.Logger) error {
if len(traceID) > 32 {
return fmt.Errorf("trace-id must be at most 32 characters, got %d (%q)", len(traceID), traceID)
}

stateStore := sflags.MustGetString(cmd, "state-store")
gcpProject := sflags.MustGetString(cmd, "gcp-project")
since := sflags.MustGetString(cmd, "since")
dateRange := sflags.MustGetString(cmd, "date-range")
showLogs, showLogsProvided := sflags.MustGetBoolProvided(cmd, "logs")
skipLogs := showLogsProvided && !showLogs
logsLimit := sflags.MustGetInt(cmd, "logs-limit")
Expand All @@ -84,16 +77,9 @@ func runConnection(ctx context.Context, args []string, cmd *cobra.Command, logge
return fmt.Errorf("--logs-limit must be greater than or equal to 0, got %d", logsLimit)
}

var startTime, endTime time.Time
if len(dateArgs) == 0 {
endTime = time.Now()
startTime = endTime.AddDate(0, 0, -7)
} else {
var err error
startTime, endTime, err = ParseLogDateRange(dateArgs)
if err != nil {
return fmt.Errorf("parsing date range: %w", err)
}
startTime, endTime, err := parseTimeRange(since, dateRange, 7*24*time.Hour)
if err != nil {
return fmt.Errorf("parsing time range: %w", err)
}

logger.Debug("connection invoked",
Expand Down
31 changes: 29 additions & 2 deletions cmd/tools/substreams/logs_connection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
func TestNewToolsLogsConnectionCmd(t *testing.T) {
cmd := NewToolsLogsConnectionCmd(zlogTest)

assert.Equal(t, "connection <trace-id> [<date-range>]", cmd.Use)
assert.Equal(t, "connection <trace-id>", cmd.Use)
assert.NotEmpty(t, cmd.Short)
assert.NotEmpty(t, cmd.Long)
assert.NotEmpty(t, cmd.Example)
Expand All @@ -26,6 +26,12 @@ func TestNewToolsLogsConnectionCmd(t *testing.T) {
gcpProjectFlag := flags.Lookup("gcp-project")
require.NotNil(t, gcpProjectFlag, "gcp-project flag should exist")

sinceFlag := flags.Lookup("since")
require.NotNil(t, sinceFlag, "since flag should exist")

dateRangeFlag := flags.Lookup("date-range")
require.NotNil(t, dateRangeFlag, "date-range flag should exist")

logsFlag := flags.Lookup("logs")
require.NotNil(t, logsFlag, "logs flag should exist")
assert.Equal(t, "false", logsFlag.DefValue)
Expand Down Expand Up @@ -100,7 +106,7 @@ func TestConnectionCommandRegistered(t *testing.T) {

connectionCmd, _, err := logsCmd.Find([]string{"connection"})
require.NoError(t, err)
assert.Equal(t, "connection <trace-id> [<date-range>]", connectionCmd.Use)
assert.Equal(t, "connection <trace-id>", connectionCmd.Use)
}

func TestConnectionCommandValidation(t *testing.T) {
Expand All @@ -114,6 +120,16 @@ func TestConnectionCommandValidation(t *testing.T) {
require.Error(t, err)
})

t.Run("extra positional argument rejected", func(t *testing.T) {
cmd := NewToolsLogsConnectionCmd(zlogTest)
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"abc123", "2h"})

err := cmd.Execute()
require.Error(t, err)
})

t.Run("missing gcp-project flag", func(t *testing.T) {
cmd := NewToolsLogsConnectionCmd(zlogTest)
cmd.SilenceUsage = true
Expand All @@ -124,6 +140,17 @@ func TestConnectionCommandValidation(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "gcp-project")
})

t.Run("since and date-range are mutually exclusive", func(t *testing.T) {
cmd := NewToolsLogsConnectionCmd(zlogTest)
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"abc123", "--since", "1h", "--date-range", "2024-01-15T10:00:00Z", "--gcp-project", "test"})

err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "mutually exclusive")
})
}

func TestPickConnectionEntries(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion cmd/tools/substreams/logs_reexec.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ The date-range is optional; when omitted it defaults to the last 7 days.

The date-range argument(s) accept various formats:

Relative: 1d 2hr 30m "1 day ago" "2 hours ago" "30 minutes ago"
Relative: 1d 2hr 30m 1w "1 day ago" "2 hours ago" "30 minutes ago"
Timestamp: 2024-01-15T10:00:00Z (past → search start to now)
Range: "2024-01-15T10:00:00Z:2024-01-15T12:00:00Z" (single arg with colon)
"2024-01-15T10:00:00Z/2024-01-15T12:00:00Z" (single arg with slash)
Expand Down
Loading
Loading