From 654295e56f35b8c7041e7f35639f72901fce9970 Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Wed, 11 Feb 2026 17:42:53 -0600 Subject: [PATCH 1/6] fix(cli): improve time parsing, interface consistency, and documentation accuracy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed several bugs found during user testing: Time Parsing Improvements: - Added RFC3339 timestamp support (e.g., "2026-02-11T08:49:36.831Z") to logs, metrics, and RUM commands - Replaced custom time parsing functions with pkg/util.ParseTimeParam and pkg/util.ParseTimeToUnixMilli - Now supports relative times (5m, 1h, 7d), Unix timestamps, RFC3339, and "now" keyword - cmd/logs_simple.go: Removed duplicate parseTimeString function - cmd/metrics.go: Removed duplicate parseTimeParam function - cmd/rum.go: Updated to use util package for time parsing - Updated help text to document RFC3339 support Interface Consistency: - Made logs --from flag optional with default "1h" (previously required) - Logs commands now match metrics command interface pattern - cmd/logs_simple.go: Removed MarkFlagRequired("from") for search, list, query, and aggregate commands Documentation Fixes: - Updated README.md to mark Traces as not implemented (was incorrectly shown as ✅) - Updated docs/COMMANDS.md to reflect Traces placeholder status - Directed users to use APM commands for trace data instead APM Services Fix: - Fixed APM services list to use filter[env] parameter format - cmd/apm.go: Changed from params.Add("env", envFilter) to params.Add("filter[env]", envFilter) - Now consistent with APM services stats command Test Updates: - cmd/logs_simple_test.go: Updated TestParseTimeString to test util.ParseTimeToUnixMilli - Added test case for RFC3339 timestamp parsing - cmd/metrics_test.go: Updated all test references to use util.ParseTimeParam Co-Authored-By: Claude Sonnet 4.5 --- README.md | 2 +- cmd/apm.go | 2 +- cmd/logs_simple.go | 80 ++++++++--------------------------------- cmd/logs_simple_test.go | 16 +++++++-- cmd/metrics.go | 54 +++------------------------- cmd/metrics_test.go | 19 +++++----- cmd/rum.go | 17 ++++----- docs/COMMANDS.md | 4 +-- 8 files changed, 55 insertions(+), 139 deletions(-) diff --git a/README.md b/README.md index 1b812011..4281a438 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ See [docs/COMMANDS.md](docs/COMMANDS.md) for detailed command reference. |------------|--------|--------------|-------| | Metrics | ✅ | `metrics search`, `metrics query`, `metrics list`, `metrics get` | V1 and V2 APIs supported | | Logs | ✅ | `logs search`, `logs list`, `logs aggregate` | V1 and V2 APIs supported | -| Traces | ✅ | `traces search`, `traces list`, `traces aggregate` | APM traces support | +| Traces | ❌ | - | Not yet implemented (use APM commands for trace data) | | Events | ✅ | `events list`, `events search`, `events get` | Infrastructure event management | | RUM | ✅ | `rum apps`, `rum sessions`, `rum metrics list/get`, `rum retention-filters list/get` | Apps, sessions, metrics, retention filters (create/update pending) | | APM Services | ✅ | `apm services`, `apm entities`, `apm dependencies`, `apm flow-map` | Services stats, operations, resources; entity queries; dependencies; flow visualization | diff --git a/cmd/apm.go b/cmd/apm.go index c316f807..67ec0ab8 100644 --- a/cmd/apm.go +++ b/cmd/apm.go @@ -549,7 +549,7 @@ func runAPMServicesList(cmd *cobra.Command, args []string) error { params.Add("start", strconv.FormatInt(startTime, 10)) params.Add("end", strconv.FormatInt(endTime, 10)) if envFilter != "" { - params.Add("env", envFilter) + params.Add("filter[env]", envFilter) } path := fmt.Sprintf("/api/v2/apm/services?%s", params.Encode()) diff --git a/cmd/logs_simple.go b/cmd/logs_simple.go index e7d02f98..b2f564e3 100644 --- a/cmd/logs_simple.go +++ b/cmd/logs_simple.go @@ -14,6 +14,7 @@ import ( "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" "github.com/DataDog/pup/pkg/formatter" + "github.com/DataDog/pup/pkg/util" "github.com/spf13/cobra" ) @@ -556,8 +557,8 @@ var ( func init() { // Search command flags (v1) logsSearchCmd.Flags().StringVar(&logsQuery, "query", "", "Search query (required)") - logsSearchCmd.Flags().StringVar(&logsFrom, "from", "", "Start time: 1h, 30m, 7d, or timestamp (required)") - logsSearchCmd.Flags().StringVar(&logsTo, "to", "now", "End time: 1h, 30m, now, or timestamp") + logsSearchCmd.Flags().StringVar(&logsFrom, "from", "1h", "Start time: 1h, 30m, 7d, RFC3339, or Unix timestamp") + logsSearchCmd.Flags().StringVar(&logsTo, "to", "now", "End time: 1h, 30m, now, RFC3339, or Unix timestamp") logsSearchCmd.Flags().IntVar(&logsLimit, "limit", 50, "Maximum number of logs (1-1000)") logsSearchCmd.Flags().StringVar(&logsSort, "sort", "desc", "Sort order: asc or desc") logsSearchCmd.Flags().StringVar(&logsIndex, "index", "", "Comma-separated log indexes") @@ -565,24 +566,18 @@ func init() { if err := logsSearchCmd.MarkFlagRequired("query"); err != nil { panic(fmt.Errorf("failed to mark flag as required: %w", err)) } - if err := logsSearchCmd.MarkFlagRequired("from"); err != nil { - panic(fmt.Errorf("failed to mark flag as required: %w", err)) - } // List command flags (v2) logsListCmd.Flags().StringVar(&logsQuery, "query", "*", "Search query") - logsListCmd.Flags().StringVar(&logsFrom, "from", "", "Start time (required)") + logsListCmd.Flags().StringVar(&logsFrom, "from", "1h", "Start time: 1h, 30m, 7d, RFC3339, or Unix timestamp") logsListCmd.Flags().StringVar(&logsTo, "to", "now", "End time") logsListCmd.Flags().IntVar(&logsLimit, "limit", 10, "Number of logs") logsListCmd.Flags().StringVar(&logsSort, "sort", "-timestamp", "Sort order") logsListCmd.Flags().StringVar(&logsStorage, "storage", "", "Storage tier: indexes, online-archives, or flex") - if err := logsListCmd.MarkFlagRequired("from"); err != nil { - panic(fmt.Errorf("failed to mark flag as required: %w", err)) - } // Query command flags (v2) logsQueryCmd.Flags().StringVar(&logsQuery, "query", "", "Log query (required)") - logsQueryCmd.Flags().StringVar(&logsFrom, "from", "", "Start time (required)") + logsQueryCmd.Flags().StringVar(&logsFrom, "from", "1h", "Start time: 1h, 30m, 7d, RFC3339, or Unix timestamp") logsQueryCmd.Flags().StringVar(&logsTo, "to", "now", "End time") logsQueryCmd.Flags().IntVar(&logsLimit, "limit", 50, "Maximum results") logsQueryCmd.Flags().StringVar(&logsSort, "sort", "-timestamp", "Sort order") @@ -591,13 +586,10 @@ func init() { if err := logsQueryCmd.MarkFlagRequired("query"); err != nil { panic(fmt.Errorf("failed to mark flag as required: %w", err)) } - if err := logsQueryCmd.MarkFlagRequired("from"); err != nil { - panic(fmt.Errorf("failed to mark flag as required: %w", err)) - } // Aggregate command flags (v2) logsAggregateCmd.Flags().StringVar(&logsQuery, "query", "", "Log query (required)") - logsAggregateCmd.Flags().StringVar(&logsFrom, "from", "", "Start time (required)") + logsAggregateCmd.Flags().StringVar(&logsFrom, "from", "1h", "Start time: 1h, 30m, 7d, RFC3339, or Unix timestamp") logsAggregateCmd.Flags().StringVar(&logsTo, "to", "now", "End time") logsAggregateCmd.Flags().StringVar(&logsCompute, "compute", "count", "Metric to compute") logsAggregateCmd.Flags().StringVar(&logsGroupBy, "group-by", "", "Field to group by") @@ -606,9 +598,6 @@ func init() { if err := logsAggregateCmd.MarkFlagRequired("query"); err != nil { panic(fmt.Errorf("failed to mark flag as required: %w", err)) } - if err := logsAggregateCmd.MarkFlagRequired("from"); err != nil { - panic(fmt.Errorf("failed to mark flag as required: %w", err)) - } // Add subcommands logsCmd.AddCommand(logsSearchCmd) @@ -641,47 +630,6 @@ func init() { // Helper functions -// parseTimeString converts relative or absolute time to Unix timestamp in milliseconds (UTC) -func parseTimeString(timeStr string) (int64, error) { - if timeStr == "now" { - return time.Now().UTC().UnixMilli(), nil - } - - // Try parsing as relative time (1h, 30m, 7d) - if len(timeStr) >= 2 { - unit := timeStr[len(timeStr)-1:] - valueStr := timeStr[:len(timeStr)-1] - - var value int64 - if _, err := fmt.Sscanf(valueStr, "%d", &value); err == nil { - var duration time.Duration - switch unit { - case "s": - duration = time.Duration(value) * time.Second - case "m": - duration = time.Duration(value) * time.Minute - case "h": - duration = time.Duration(value) * time.Hour - case "d": - duration = time.Duration(value) * 24 * time.Hour - case "w": - duration = time.Duration(value) * 7 * 24 * time.Hour - default: - return 0, fmt.Errorf("invalid time unit: %s (use s, m, h, d, or w)", unit) - } - return time.Now().UTC().Add(-duration).UnixMilli(), nil - } - } - - // Try parsing as Unix timestamp (milliseconds) - var timestamp int64 - if _, err := fmt.Sscanf(timeStr, "%d", ×tamp); err == nil { - return timestamp, nil - } - - return 0, fmt.Errorf("invalid time format: %s (use relative like '1h' or Unix timestamp)", timeStr) -} - // validateAndConvertStorageTier validates the storage tier string and converts it to LogsStorageTier // Returns nil if storage is empty (which means search all tiers) func validateAndConvertStorageTier(storage string) (*datadogV2.LogsStorageTier, error) { @@ -782,12 +730,12 @@ func runLogsSearch(cmd *cobra.Command, args []string) error { return err } - fromTime, err := parseTimeString(logsFrom) + fromTime, err := util.ParseTimeToUnixMilli(logsFrom) if err != nil { return fmt.Errorf("invalid --from time: %w", err) } - toTime, err := parseTimeString(logsTo) + toTime, err := util.ParseTimeToUnixMilli(logsTo) if err != nil { return fmt.Errorf("invalid --to time: %w", err) } @@ -930,12 +878,12 @@ func runLogsList(cmd *cobra.Command, args []string) error { return err } - fromTime, err := parseTimeString(logsFrom) + fromTime, err := util.ParseTimeToUnixMilli(logsFrom) if err != nil { return fmt.Errorf("invalid --from time: %w", err) } - toTime, err := parseTimeString(logsTo) + toTime, err := util.ParseTimeToUnixMilli(logsTo) if err != nil { return fmt.Errorf("invalid --to time: %w", err) } @@ -1000,12 +948,12 @@ func runLogsQuery(cmd *cobra.Command, args []string) error { return err } - fromTime, err := parseTimeString(logsFrom) + fromTime, err := util.ParseTimeToUnixMilli(logsFrom) if err != nil { return fmt.Errorf("invalid --from time: %w", err) } - toTime, err := parseTimeString(logsTo) + toTime, err := util.ParseTimeToUnixMilli(logsTo) if err != nil { return fmt.Errorf("invalid --to time: %w", err) } @@ -1072,12 +1020,12 @@ func runLogsAggregate(cmd *cobra.Command, args []string) error { return err } - fromTime, err := parseTimeString(logsFrom) + fromTime, err := util.ParseTimeToUnixMilli(logsFrom) if err != nil { return fmt.Errorf("invalid --from time: %w", err) } - toTime, err := parseTimeString(logsTo) + toTime, err := util.ParseTimeToUnixMilli(logsTo) if err != nil { return fmt.Errorf("invalid --to time: %w", err) } diff --git a/cmd/logs_simple_test.go b/cmd/logs_simple_test.go index e95fa3e2..a7afa597 100644 --- a/cmd/logs_simple_test.go +++ b/cmd/logs_simple_test.go @@ -14,6 +14,7 @@ import ( "github.com/DataDog/pup/pkg/client" "github.com/DataDog/pup/pkg/config" + "github.com/DataDog/pup/pkg/util" ) func TestLogsCmd(t *testing.T) { @@ -58,6 +59,15 @@ func TestParseTimeString(t *testing.T) { return ts > 1000000000000 && ts < 9999999999999 }, }, + { + name: "RFC3339 timestamp", + input: "2024-01-01T00:00:00Z", + check: func(ts int64) bool { + // Should be Jan 1, 2024 in milliseconds + // 2024-01-01T00:00:00Z = 1704067200000ms + return ts == 1704067200000 + }, + }, { name: "invalid format", input: "invalid", @@ -67,15 +77,15 @@ func TestParseTimeString(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := parseTimeString(tt.input) + got, err := util.ParseTimeToUnixMilli(tt.input) if (err != nil) != tt.wantErr { - t.Errorf("parseTimeString() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("util.ParseTimeToUnixMilli() error = %v, wantErr %v", err, tt.wantErr) return } if !tt.wantErr && tt.check != nil && !tt.check(got) { - t.Errorf("parseTimeString() = %d, validation failed", got) + t.Errorf("util.ParseTimeToUnixMilli() = %d, validation failed", got) } }) } diff --git a/cmd/metrics.go b/cmd/metrics.go index f7622223..3e5eeb8f 100644 --- a/cmd/metrics.go +++ b/cmd/metrics.go @@ -16,6 +16,7 @@ import ( "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" "github.com/DataDog/pup/pkg/formatter" + "github.com/DataDog/pup/pkg/util" "github.com/spf13/cobra" ) @@ -523,12 +524,12 @@ func runMetricsQuery(cmd *cobra.Command, args []string) error { } // Parse time ranges - from, err := parseTimeParam(fromTime) + from, err := util.ParseTimeParam(fromTime) if err != nil { return fmt.Errorf("invalid --from time: %w", err) } - to, err := parseTimeParam(toTime) + to, err := util.ParseTimeParam(toTime) if err != nil { return fmt.Errorf("invalid --to time: %w", err) } @@ -589,12 +590,12 @@ func runMetricsSearch(cmd *cobra.Command, args []string) error { } // Parse time ranges - from, err := parseTimeParam(fromTime) + from, err := util.ParseTimeParam(fromTime) if err != nil { return fmt.Errorf("invalid --from time: %w", err) } - to, err := parseTimeParam(toTime) + to, err := util.ParseTimeParam(toTime) if err != nil { return fmt.Errorf("invalid --to time: %w", err) } @@ -859,48 +860,3 @@ func runMetricsTagsList(cmd *cobra.Command, args []string) error { return fmt.Errorf("listing tags by metric name is not supported in the current API client version") } -// parseTimeParam parses a time parameter (relative or absolute) -func parseTimeParam(timeStr string) (time.Time, error) { - // Handle "now" keyword - if strings.ToLower(timeStr) == "now" { - return time.Now(), nil - } - - // Try parsing as unix timestamp - if timestamp, err := strconv.ParseInt(timeStr, 10, 64); err == nil { - return time.Unix(timestamp, 0), nil - } - - // Parse relative time (e.g., 1h, 30m, 7d, 1w) - if len(timeStr) < 2 { - return time.Time{}, fmt.Errorf("invalid time format: %s", timeStr) - } - - valueStr := timeStr[:len(timeStr)-1] - unit := timeStr[len(timeStr)-1:] - - value, err := strconv.ParseInt(valueStr, 10, 64) - if err != nil { - return time.Time{}, fmt.Errorf("invalid time value: %s", timeStr) - } - - now := time.Now() - var duration time.Duration - - switch strings.ToLower(unit) { - case "s": - duration = time.Duration(value) * time.Second - case "m": - duration = time.Duration(value) * time.Minute - case "h": - duration = time.Duration(value) * time.Hour - case "d": - duration = time.Duration(value) * 24 * time.Hour - case "w": - duration = time.Duration(value) * 7 * 24 * time.Hour - default: - return time.Time{}, fmt.Errorf("invalid time unit: %s (use s, m, h, d, or w)", unit) - } - - return now.Add(-duration), nil -} diff --git a/cmd/metrics_test.go b/cmd/metrics_test.go index bfd002e6..0046a39b 100644 --- a/cmd/metrics_test.go +++ b/cmd/metrics_test.go @@ -15,6 +15,7 @@ import ( "github.com/DataDog/pup/pkg/client" "github.com/DataDog/pup/pkg/config" + "github.com/DataDog/pup/pkg/util" ) func TestMetricsCmd(t *testing.T) { @@ -529,16 +530,16 @@ func TestParseTimeParam(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := parseTimeParam(tt.timeStr) + result, err := util.ParseTimeParam(tt.timeStr) if (err != nil) != tt.wantErr { - t.Errorf("parseTimeParam(%q) error = %v, wantErr %v", tt.timeStr, err, tt.wantErr) + t.Errorf("util.ParseTimeParam(%q) error = %v, wantErr %v", tt.timeStr, err, tt.wantErr) } // Validate result for successful cases if err == nil { if result.IsZero() { - t.Errorf("parseTimeParam(%q) returned zero time", tt.timeStr) + t.Errorf("util.ParseTimeParam(%q) returned zero time", tt.timeStr) } } }) @@ -574,23 +575,23 @@ func TestParseTimeParam_RelativeTime(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := parseTimeParam(tt.timeStr) + result, err := util.ParseTimeParam(tt.timeStr) if err != nil { - t.Fatalf("parseTimeParam(%q) unexpected error: %v", tt.timeStr, err) + t.Fatalf("util.ParseTimeParam(%q) unexpected error: %v", tt.timeStr, err) } now := time.Now() if tt.expectPast && result.After(now) { - t.Errorf("parseTimeParam(%q) = %v, expected time in the past", tt.timeStr, result) + t.Errorf("util.ParseTimeParam(%q) = %v, expected time in the past", tt.timeStr, result) } }) } } func TestParseTimeParam_NowKeyword(t *testing.T) { - result, err := parseTimeParam("now") + result, err := util.ParseTimeParam("now") if err != nil { - t.Fatalf("parseTimeParam(\"now\") unexpected error: %v", err) + t.Fatalf("util.ParseTimeParam(\"now\") unexpected error: %v", err) } now := time.Now() @@ -598,6 +599,6 @@ func TestParseTimeParam_NowKeyword(t *testing.T) { // Should be very close to current time (within 1 second) if diff > time.Second || diff < -time.Second { - t.Errorf("parseTimeParam(\"now\") = %v, too far from current time %v (diff: %v)", result, now, diff) + t.Errorf("util.ParseTimeParam(\"now\") = %v, too far from current time %v (diff: %v)", result, now, diff) } } diff --git a/cmd/rum.go b/cmd/rum.go index 16c9fc55..74c2931b 100644 --- a/cmd/rum.go +++ b/cmd/rum.go @@ -12,6 +12,7 @@ import ( "github.com/DataDog/datadog-api-client-go/v2/api/datadog" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" "github.com/DataDog/pup/pkg/formatter" + "github.com/DataDog/pup/pkg/util" "github.com/spf13/cobra" ) @@ -658,14 +659,14 @@ func runRumRetentionFiltersDelete(cmd *cobra.Command, args []string) error { // RUM Sessions Implementation func runRumSessionsList(cmd *cobra.Command, args []string) error { // Convert relative time strings to absolute timestamps (validate input first) - fromTime, err := parseTimeString(rumFrom) + fromTime, err := util.ParseTimeToUnixMilli(rumFrom) if err != nil { - return fmt.Errorf("invalid --from time: %w\n\nSupported formats:\n- Relative: 1h, 30m, 7d, 1w (hour, minute, day, week)\n- Absolute: Unix timestamp in milliseconds\n- now: Current time", err) + return fmt.Errorf("invalid --from time: %w\n\nSupported formats:\n- Relative: 1h, 30m, 7d, 1w (hour, minute, day, week)\n- Absolute: Unix timestamp in milliseconds\n- RFC3339: 2024-01-01T00:00:00Z\n- now: Current time", err) } - toTime, err := parseTimeString(rumTo) + toTime, err := util.ParseTimeToUnixMilli(rumTo) if err != nil { - return fmt.Errorf("invalid --to time: %w\n\nSupported formats:\n- Relative: 1h, 30m, 7d, 1w (hour, minute, day, week)\n- Absolute: Unix timestamp in milliseconds\n- now: Current time", err) + return fmt.Errorf("invalid --to time: %w\n\nSupported formats:\n- Relative: 1h, 30m, 7d, 1w (hour, minute, day, week)\n- Absolute: Unix timestamp in milliseconds\n- RFC3339: 2024-01-01T00:00:00Z\n- now: Current time", err) } // Convert timestamps to strings for RUM API @@ -706,14 +707,14 @@ func runRumSessionsList(cmd *cobra.Command, args []string) error { func runRumSessionsSearch(cmd *cobra.Command, args []string) error { // Convert relative time strings to absolute timestamps (validate input first) - fromTime, err := parseTimeString(rumFrom) + fromTime, err := util.ParseTimeToUnixMilli(rumFrom) if err != nil { - return fmt.Errorf("invalid --from time: %w\n\nSupported formats:\n- Relative: 1h, 30m, 7d, 1w (hour, minute, day, week)\n- Absolute: Unix timestamp in milliseconds\n- now: Current time", err) + return fmt.Errorf("invalid --from time: %w\n\nSupported formats:\n- Relative: 1h, 30m, 7d, 1w (hour, minute, day, week)\n- Absolute: Unix timestamp in milliseconds\n- RFC3339: 2024-01-01T00:00:00Z\n- now: Current time", err) } - toTime, err := parseTimeString(rumTo) + toTime, err := util.ParseTimeToUnixMilli(rumTo) if err != nil { - return fmt.Errorf("invalid --to time: %w\n\nSupported formats:\n- Relative: 1h, 30m, 7d, 1w (hour, minute, day, week)\n- Absolute: Unix timestamp in milliseconds\n- now: Current time", err) + return fmt.Errorf("invalid --to time: %w\n\nSupported formats:\n- Relative: 1h, 30m, 7d, 1w (hour, minute, day, week)\n- Absolute: Unix timestamp in milliseconds\n- RFC3339: 2024-01-01T00:00:00Z\n- now: Current time", err) } // Convert timestamps to strings for RUM API diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 13680789..e01620f8 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -22,7 +22,7 @@ pup [options] # Nested commands | auth | login, logout, status, refresh | cmd/auth.go | ✅ | | metrics | query, list, get, search | cmd/metrics.go | ✅ | | logs | search, list, aggregate | cmd/logs.go | ✅ | -| traces | search, list, aggregate | cmd/traces.go | ✅ | +| traces | - | cmd/traces_simple.go | ❌ | | monitors | list, get, delete, search | cmd/monitors.go | ✅ | | dashboards | list, get, delete, url | cmd/dashboards.go | ✅ | | slos | list, get, create, update, delete, corrections | cmd/slos.go | ✅ | @@ -108,7 +108,7 @@ pup infrastructure hosts list ### Data & Observability - **metrics** - Time-series metrics (query, list, get, search) - **logs** - Log search and analysis (search, list, aggregate) -- **traces** - APM traces (search, list, aggregate) +- **traces** - APM traces (not yet implemented - use `apm` commands instead) - **rum** - Real User Monitoring (apps, metrics, retention-filters, sessions) - **events** - Infrastructure events (list, search, get) From e1934ea01dca54effa69cbeb5faaec5c3c1b9f5c Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Wed, 11 Feb 2026 17:43:58 -0600 Subject: [PATCH 2/6] fix(apm): set default environment filter to 'prod' for services list Changed the default value for --env flag from empty string to "prod" for the apm services list command to improve usability. - cmd/apm.go: Updated apmServicesListCmd --env default from "" to "prod" Co-Authored-By: Claude Sonnet 4.5 --- cmd/apm.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/apm.go b/cmd/apm.go index 67ec0ab8..66f1bbcc 100644 --- a/cmd/apm.go +++ b/cmd/apm.go @@ -428,7 +428,7 @@ var ( func init() { // Services list flags - apmServicesListCmd.Flags().StringVar(&envFilter, "env", "", "Environment filter") + apmServicesListCmd.Flags().StringVar(&envFilter, "env", "prod", "Environment filter") apmServicesListCmd.Flags().Int64Var(&startTime, "start", time.Now().Add(-1*time.Hour).Unix(), "Start time (Unix timestamp)") apmServicesListCmd.Flags().Int64Var(&endTime, "end", time.Now().Unix(), "End time (Unix timestamp)") From bb5731c706bf855707e11f578368351af0c7c296 Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Wed, 11 Feb 2026 17:48:33 -0600 Subject: [PATCH 3/6] feat(time): add flexible time format support with case-insensitive parsing Enhanced time parsing to support natural language variations and case-insensitive formats for improved user experience. New Supported Formats: - Long forms: 5min, 5minutes, 2hr, 2hours, 3days, 1week - Plural variations: mins, hrs, days, weeks - With spaces: "5 minutes", "2 hours" - Minus prefix: -5m, -2h, -10minutes (gracefully handled) - Case-insensitive: NOW, 5MIN, 2HOURS, etc. Changes: - pkg/util/time.go: Enhanced regex to match long forms and spaces - pkg/util/time.go: Added case-insensitive matching for all formats - pkg/util/time_test.go: Added comprehensive tests for all new variations - cmd/logs_simple.go: Updated help text to document new formats All existing functionality preserved, fully backward compatible. Co-Authored-By: Claude Sonnet 4.5 --- cmd/logs_simple.go | 16 ++++--- pkg/util/time.go | 33 +++++++++---- pkg/util/time_test.go | 109 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 140 insertions(+), 18 deletions(-) diff --git a/cmd/logs_simple.go b/cmd/logs_simple.go index b2f564e3..edddcec5 100644 --- a/cmd/logs_simple.go +++ b/cmd/logs_simple.go @@ -54,8 +54,12 @@ LOG QUERY SYNTAX: TIME RANGES: Supported time formats: - • Relative: 1h, 30m, 7d, 1w (hour, minute, day, week) + • Relative short: 1h, 30m, 7d, 5s, 1w + • Relative long: 5min, 5minutes, 2hr, 2hours, 3days, 1week + • With spaces: "5 minutes", "2 hours" + • With minus: -5m, -2h (treated same as 5m, 2h) • Absolute: Unix timestamp in milliseconds + • RFC3339: 2024-01-01T00:00:00Z • now: Current time EXAMPLES: @@ -557,8 +561,8 @@ var ( func init() { // Search command flags (v1) logsSearchCmd.Flags().StringVar(&logsQuery, "query", "", "Search query (required)") - logsSearchCmd.Flags().StringVar(&logsFrom, "from", "1h", "Start time: 1h, 30m, 7d, RFC3339, or Unix timestamp") - logsSearchCmd.Flags().StringVar(&logsTo, "to", "now", "End time: 1h, 30m, now, RFC3339, or Unix timestamp") + logsSearchCmd.Flags().StringVar(&logsFrom, "from", "1h", "Start time: 1h, 5min, 2hours, '5 minutes', RFC3339, Unix timestamp, or 'now'") + logsSearchCmd.Flags().StringVar(&logsTo, "to", "now", "End time: 1h, 5min, 2hours, '5 minutes', RFC3339, Unix timestamp, or 'now'") logsSearchCmd.Flags().IntVar(&logsLimit, "limit", 50, "Maximum number of logs (1-1000)") logsSearchCmd.Flags().StringVar(&logsSort, "sort", "desc", "Sort order: asc or desc") logsSearchCmd.Flags().StringVar(&logsIndex, "index", "", "Comma-separated log indexes") @@ -569,7 +573,7 @@ func init() { // List command flags (v2) logsListCmd.Flags().StringVar(&logsQuery, "query", "*", "Search query") - logsListCmd.Flags().StringVar(&logsFrom, "from", "1h", "Start time: 1h, 30m, 7d, RFC3339, or Unix timestamp") + logsListCmd.Flags().StringVar(&logsFrom, "from", "1h", "Start time: 1h, 5min, 2hours, '5 minutes', RFC3339, Unix timestamp, or 'now'") logsListCmd.Flags().StringVar(&logsTo, "to", "now", "End time") logsListCmd.Flags().IntVar(&logsLimit, "limit", 10, "Number of logs") logsListCmd.Flags().StringVar(&logsSort, "sort", "-timestamp", "Sort order") @@ -577,7 +581,7 @@ func init() { // Query command flags (v2) logsQueryCmd.Flags().StringVar(&logsQuery, "query", "", "Log query (required)") - logsQueryCmd.Flags().StringVar(&logsFrom, "from", "1h", "Start time: 1h, 30m, 7d, RFC3339, or Unix timestamp") + logsQueryCmd.Flags().StringVar(&logsFrom, "from", "1h", "Start time: 1h, 5min, 2hours, '5 minutes', RFC3339, Unix timestamp, or 'now'") logsQueryCmd.Flags().StringVar(&logsTo, "to", "now", "End time") logsQueryCmd.Flags().IntVar(&logsLimit, "limit", 50, "Maximum results") logsQueryCmd.Flags().StringVar(&logsSort, "sort", "-timestamp", "Sort order") @@ -589,7 +593,7 @@ func init() { // Aggregate command flags (v2) logsAggregateCmd.Flags().StringVar(&logsQuery, "query", "", "Log query (required)") - logsAggregateCmd.Flags().StringVar(&logsFrom, "from", "1h", "Start time: 1h, 30m, 7d, RFC3339, or Unix timestamp") + logsAggregateCmd.Flags().StringVar(&logsFrom, "from", "1h", "Start time: 1h, 5min, 2hours, '5 minutes', RFC3339, Unix timestamp, or 'now'") logsAggregateCmd.Flags().StringVar(&logsTo, "to", "now", "End time") logsAggregateCmd.Flags().StringVar(&logsCompute, "compute", "count", "Metric to compute") logsAggregateCmd.Flags().StringVar(&logsGroupBy, "group-by", "", "Field to group by") diff --git a/pkg/util/time.go b/pkg/util/time.go index e80f6c14..744522f0 100644 --- a/pkg/util/time.go +++ b/pkg/util/time.go @@ -9,17 +9,25 @@ import ( "fmt" "regexp" "strconv" + "strings" "time" ) // ParseTimeParam parses time parameters supporting multiple formats: // - "now" for current time // - Unix timestamps (e.g., "1704067200") -// - Relative time (e.g., "1h", "30m", "7d") +// - Relative time with flexible formats: +// - Short: "1h", "30m", "7d", "5s", "1w" +// - Long: "5min", "5mins", "5minute", "5minutes" +// - Long: "2hr", "2hrs", "2hour", "2hours" +// - Long: "3day", "3days" +// - Long: "1week", "1weeks" +// - With spaces: "5 minutes", "2 hours" +// - With minus prefix: "-5m", "-2h" (treated same as "5m", "2h") // - ISO date strings (e.g., "2024-01-01T00:00:00Z") func ParseTimeParam(timeStr string) (time.Time, error) { - // Handle "now" - if timeStr == "now" { + // Handle "now" (case-insensitive) + if strings.ToLower(timeStr) == "now" { return time.Now(), nil } @@ -31,8 +39,10 @@ func ParseTimeParam(timeStr string) (time.Time, error) { } } - // Try parsing relative time (e.g., "1h", "30m", "2d") - re := regexp.MustCompile(`^(\d+)([smhd])$`) + // Try parsing relative time with flexible formats + // Supports: 5m, 5min, 5mins, 5minute, 5minutes, 5 minutes, -5m, etc. + // Case-insensitive to handle MIN, Hour, HOURS, etc. + re := regexp.MustCompile(`(?i)^-?(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks)$`) matches := re.FindStringSubmatch(timeStr) if len(matches) == 3 { value, err := strconv.Atoi(matches[1]) @@ -40,18 +50,21 @@ func ParseTimeParam(timeStr string) (time.Time, error) { return time.Time{}, fmt.Errorf("invalid time value: %w", err) } - unit := matches[2] + unit := strings.ToLower(matches[2]) var duration time.Duration + // Map all variations to their base duration switch unit { - case "s": + case "s", "sec", "secs", "second", "seconds": duration = time.Duration(value) * time.Second - case "m": + case "m", "min", "mins", "minute", "minutes": duration = time.Duration(value) * time.Minute - case "h": + case "h", "hr", "hrs", "hour", "hours": duration = time.Duration(value) * time.Hour - case "d": + case "d", "day", "days": duration = time.Duration(value) * 24 * time.Hour + case "w", "week", "weeks": + duration = time.Duration(value) * 7 * 24 * time.Hour } return time.Now().Add(-duration), nil diff --git a/pkg/util/time_test.go b/pkg/util/time_test.go index 498eb20a..37ef2b01 100644 --- a/pkg/util/time_test.go +++ b/pkg/util/time_test.go @@ -96,9 +96,114 @@ func TestParseTimeParam(t *testing.T) { wantError: true, }, { - name: "negative value", + name: "negative value (minus prefix)", input: "-5h", - wantError: true, + wantError: false, + checkFunc: func(t time.Time) bool { + expected := now.Add(-5 * time.Hour) + diff := expected.Sub(t).Abs() + return diff < time.Second + }, + }, + { + name: "long form: minutes", + input: "5minutes", + wantError: false, + checkFunc: func(t time.Time) bool { + expected := now.Add(-5 * time.Minute) + diff := expected.Sub(t).Abs() + return diff < time.Second + }, + }, + { + name: "long form: min", + input: "10min", + wantError: false, + checkFunc: func(t time.Time) bool { + expected := now.Add(-10 * time.Minute) + diff := expected.Sub(t).Abs() + return diff < time.Second + }, + }, + { + name: "long form: hours", + input: "2hours", + wantError: false, + checkFunc: func(t time.Time) bool { + expected := now.Add(-2 * time.Hour) + diff := expected.Sub(t).Abs() + return diff < time.Second + }, + }, + { + name: "long form: hr", + input: "3hr", + wantError: false, + checkFunc: func(t time.Time) bool { + expected := now.Add(-3 * time.Hour) + diff := expected.Sub(t).Abs() + return diff < time.Second + }, + }, + { + name: "long form: hrs", + input: "4hrs", + wantError: false, + checkFunc: func(t time.Time) bool { + expected := now.Add(-4 * time.Hour) + diff := expected.Sub(t).Abs() + return diff < time.Second + }, + }, + { + name: "long form: days", + input: "14days", + wantError: false, + checkFunc: func(t time.Time) bool { + expected := now.Add(-14 * 24 * time.Hour) + diff := expected.Sub(t).Abs() + return diff < time.Second + }, + }, + { + name: "long form: weeks", + input: "2weeks", + wantError: false, + checkFunc: func(t time.Time) bool { + expected := now.Add(-2 * 7 * 24 * time.Hour) + diff := expected.Sub(t).Abs() + return diff < time.Second + }, + }, + { + name: "with space: minutes", + input: "5 minutes", + wantError: false, + checkFunc: func(t time.Time) bool { + expected := now.Add(-5 * time.Minute) + diff := expected.Sub(t).Abs() + return diff < time.Second + }, + }, + { + name: "with space: hours", + input: "2 hours", + wantError: false, + checkFunc: func(t time.Time) bool { + expected := now.Add(-2 * time.Hour) + diff := expected.Sub(t).Abs() + return diff < time.Second + }, + }, + { + name: "with minus prefix and long form", + input: "-10minutes", + wantError: false, + checkFunc: func(t time.Time) bool { + expected := now.Add(-10 * time.Minute) + diff := expected.Sub(t).Abs() + return diff < time.Second + }, }, { name: "empty string", From 2ccb98dfc77b0d3e92e08d18a245b5d3d979716c Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Wed, 11 Feb 2026 17:57:19 -0600 Subject: [PATCH 4/6] fix(apm): make env filter required for services list command Changed the --env flag from having a default value to being explicitly required, as the API requires this parameter and was rejecting requests even with the default value. Changes: - cmd/apm.go: Marked --env flag as required for apm services list - cmd/apm.go: Added validation to return clear error if env is not provided - cmd/apm.go: Always add filter[env] parameter (removed conditional check) - Updated error message to guide users: "--env flag is required (e.g., --env prod)" This provides clearer feedback to users that the env parameter is mandatory for listing APM services. Co-Authored-By: Claude Sonnet 4.5 --- cmd/apm.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/cmd/apm.go b/cmd/apm.go index 66f1bbcc..556310dc 100644 --- a/cmd/apm.go +++ b/cmd/apm.go @@ -428,7 +428,10 @@ var ( func init() { // Services list flags - apmServicesListCmd.Flags().StringVar(&envFilter, "env", "prod", "Environment filter") + apmServicesListCmd.Flags().StringVar(&envFilter, "env", "", "Environment filter (required)") + if err := apmServicesListCmd.MarkFlagRequired("env"); err != nil { + panic(fmt.Errorf("failed to mark flag as required: %w", err)) + } apmServicesListCmd.Flags().Int64Var(&startTime, "start", time.Now().Add(-1*time.Hour).Unix(), "Start time (Unix timestamp)") apmServicesListCmd.Flags().Int64Var(&endTime, "end", time.Now().Unix(), "End time (Unix timestamp)") @@ -544,13 +547,16 @@ func runAPMServicesList(cmd *cobra.Command, args []string) error { return err } + // Validate that env filter is provided + if envFilter == "" { + return fmt.Errorf("--env flag is required (e.g., --env prod)") + } + // Build query parameters params := url.Values{} params.Add("start", strconv.FormatInt(startTime, 10)) params.Add("end", strconv.FormatInt(endTime, 10)) - if envFilter != "" { - params.Add("filter[env]", envFilter) - } + params.Add("filter[env]", envFilter) path := fmt.Sprintf("/api/v2/apm/services?%s", params.Encode()) resp, err := client.RawRequest("GET", path, nil) From 607c017b3115ef83fad1d999e9c87a2d63d57d2d Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Wed, 11 Feb 2026 17:58:04 -0600 Subject: [PATCH 5/6] fix(apm): add detailed error messages for services list debugging Added request details to error messages to help debug API parameter issues. Error messages now show the full request path and all parameters being sent. Co-Authored-By: Claude Sonnet 4.5 --- cmd/apm.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/apm.go b/cmd/apm.go index 556310dc..937627c5 100644 --- a/cmd/apm.go +++ b/cmd/apm.go @@ -561,13 +561,15 @@ func runAPMServicesList(cmd *cobra.Command, args []string) error { path := fmt.Sprintf("/api/v2/apm/services?%s", params.Encode()) resp, err := client.RawRequest("GET", path, nil) if err != nil { - return fmt.Errorf("failed to list APM services: %w", err) + return fmt.Errorf("failed to list APM services: %w\n\nRequest: GET %s\nParameters: start=%d, end=%d, filter[env]=%s", + err, path, startTime, endTime, envFilter) } defer func() { _ = resp.Body.Close() }() result, err := readRawResponse(resp) if err != nil { - return fmt.Errorf("failed to list APM services: %w", err) + return fmt.Errorf("failed to list APM services: %w\n\nRequest: GET %s\nParameters: start=%d, end=%d, filter[env]=%s", + err, path, startTime, endTime, envFilter) } output, err := formatter.FormatOutput(result, formatter.OutputFormat(outputFormat)) From d7d4189f2b7a7ca9a8b8b883f4658b358e399d6e Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Wed, 11 Feb 2026 17:59:04 -0600 Subject: [PATCH 6/6] fix(apm): calculate default timestamps at runtime for services list Fixed issue where start and end times were 0 instead of using proper defaults. The problem was that package-level variables were shared across commands and weren't being initialized with the default values. Now calculates default timestamps (1 hour ago and now) at runtime in the runAPMServicesList function if the values are 0. This fixes the "Invalid Parameter" error that was caused by sending start=0 and end=0 to the API. Co-Authored-By: Claude Sonnet 4.5 --- cmd/apm.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cmd/apm.go b/cmd/apm.go index 937627c5..8bd46b6b 100644 --- a/cmd/apm.go +++ b/cmd/apm.go @@ -552,6 +552,14 @@ func runAPMServicesList(cmd *cobra.Command, args []string) error { return fmt.Errorf("--env flag is required (e.g., --env prod)") } + // Set default time range if not provided (shared variables may be 0) + if startTime == 0 { + startTime = time.Now().Add(-1 * time.Hour).Unix() + } + if endTime == 0 { + endTime = time.Now().Unix() + } + // Build query parameters params := url.Values{} params.Add("start", strconv.FormatInt(startTime, 10))