diff --git a/cmd/metrics.go b/cmd/metrics.go index 5ce4df99..4f302642 100644 --- a/cmd/metrics.go +++ b/cmd/metrics.go @@ -165,38 +165,47 @@ var metricsListCmd = &cobra.Command{ Long: `List all available metrics in your Datadog account. This command retrieves the list of all metrics that have been submitted to -Datadog. You can optionally filter the list using a metric name pattern. +Datadog. You can optionally filter the list using a metric name pattern or +filter by Datadog tags. -FILTERING: - Use the --filter flag to search for metrics matching a pattern: +FILTERING BY NAME: + Use the --filter flag to search for metrics matching a name pattern: • system.* - All system metrics • *.cpu.* - All CPU-related metrics • custom.* - All custom metrics • myapp.* - All metrics starting with myapp + • *request* - All metrics containing "request" + + Pattern matching supports wildcards (* and ?) and is case-sensitive. + Filtering is performed client-side after fetching all metrics. + +FILTERING BY TAGS: + Use the --tag-filter flag to filter by Datadog tags (comma-separated): + • env:prod - Metrics tagged with env:prod + • env:prod,service:api - Metrics tagged with both env:prod AND service:api + + Tag filtering is performed server-side by the Datadog API and returns + metrics that match ALL specified tags. EXAMPLES: # List all metrics pup metrics list - # List system metrics + # Filter by metric name pattern pup metrics list --filter="system.*" - - # List CPU metrics pup metrics list --filter="*.cpu.*" + pup metrics list --filter="*request*" - # List custom metrics - pup metrics list --filter="custom.*" + # Filter by tags + pup metrics list --tag-filter="env:prod" + pup metrics list --tag-filter="env:prod,service:api" - # Search for specific metrics - pup metrics list --filter="*request*" + # Combine both filters (name pattern + tags) + pup metrics list --filter="system.*" --tag-filter="env:prod" OUTPUT: Returns an array of metric names. The response may be paginated for - large metric sets. - -PAGINATION: - Currently returns all matching metrics. For very large metric sets, - consider using more specific filters.`, + large metric sets.`, RunE: runMetricsList, } @@ -435,6 +444,7 @@ var ( // List flags filterPattern string + tagFilter string // Metadata update flags metadataDescription string @@ -471,7 +481,8 @@ func init() { } // List command flags - metricsListCmd.Flags().StringVar(&filterPattern, "filter", "", "Filter metrics by pattern (e.g., system.*)") + metricsListCmd.Flags().StringVar(&filterPattern, "filter", "", "Filter metrics by name pattern (e.g., system.*, *.cpu.*)") + metricsListCmd.Flags().StringVar(&tagFilter, "tag-filter", "", "Filter metrics by tags (e.g., env:prod,service:api)") // Metadata update flags metricsMetadataUpdateCmd.Flags().StringVar(&metadataDescription, "description", "", "Metric description") @@ -623,6 +634,69 @@ func runMetricsSearch(cmd *cobra.Command, args []string) error { return nil } +// matchMetricName checks if a metric name matches a wildcard pattern. +// Supports * (match any characters) and ? (match single character). +func matchMetricName(pattern, name string) bool { + // If no pattern, match all + if pattern == "" { + return true + } + + // Simple case: exact match + if pattern == name { + return true + } + + // Convert glob pattern to regex-like matching + pIdx, nIdx := 0, 0 + pLen, nLen := len(pattern), len(name) + + // Track position for backtracking after * + var starIdx, matchIdx int = -1, 0 + + for nIdx < nLen { + if pIdx < pLen { + switch pattern[pIdx] { + case '?': + // ? matches any single character + pIdx++ + nIdx++ + continue + case '*': + // * matches zero or more characters + starIdx = pIdx + matchIdx = nIdx + pIdx++ + continue + default: + // Regular character must match exactly + if pattern[pIdx] == name[nIdx] { + pIdx++ + nIdx++ + continue + } + } + } + + // If we have a star, try backtracking + if starIdx != -1 { + pIdx = starIdx + 1 + matchIdx++ + nIdx = matchIdx + continue + } + + return false + } + + // Consume remaining * in pattern + for pIdx < pLen && pattern[pIdx] == '*' { + pIdx++ + } + + return pIdx == pLen +} + // runMetricsList executes the metrics list command func runMetricsList(cmd *cobra.Command, args []string) error { client, err := getClient() @@ -635,9 +709,10 @@ func runMetricsList(cmd *cobra.Command, args []string) error { // From time defaults to 1 hour ago from := time.Now().Add(-1 * time.Hour).Unix() + // Build API options - only use tag filter for --tag-filter flag opts := datadogV1.NewListActiveMetricsOptionalParameters() - if filterPattern != "" { - opts = opts.WithTagFilter(filterPattern) + if tagFilter != "" { + opts = opts.WithTagFilter(tagFilter) } resp, r, err := api.ListActiveMetrics(client.Context(), from, *opts) @@ -645,9 +720,21 @@ func runMetricsList(cmd *cobra.Command, args []string) error { if r != nil { apiBody := extractAPIErrorBody(err) if apiBody != "" { - return fmt.Errorf("failed to list metrics: %w\nStatus: %d\nAPI Response: %s\n\nRequest Details:\n- Filter: %s\n- From: %s (Unix: %d)\n\nTroubleshooting:\n- Check that your filter pattern is valid\n- Verify you have permissions to list metrics", + filters := []string{} + if filterPattern != "" { + filters = append(filters, fmt.Sprintf("Name pattern: %s", filterPattern)) + } + if tagFilter != "" { + filters = append(filters, fmt.Sprintf("Tag filter: %s", tagFilter)) + } + filterInfo := strings.Join(filters, "\n- ") + if filterInfo == "" { + filterInfo = "None" + } + + return fmt.Errorf("failed to list metrics: %w\nStatus: %d\nAPI Response: %s\n\nRequest Details:\n- Filters:\n %s\n- From: %s (Unix: %d)\n\nTroubleshooting:\n- For --filter, use metric name patterns (e.g., system.*, *.cpu.*)\n- For --tag-filter, use Datadog tags (e.g., env:prod,service:api)\n- Verify you have permissions to list metrics", err, r.StatusCode, apiBody, - filterPattern, + filterInfo, time.Unix(from, 0).Format(time.RFC3339), from) } return fmt.Errorf("failed to list metrics: %w (status: %d)", err, r.StatusCode) @@ -655,6 +742,20 @@ func runMetricsList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list metrics: %w", err) } + // Apply client-side name filtering if pattern is specified + if filterPattern != "" { + metrics, ok := resp.GetMetricsOk() + if ok && metrics != nil { + filtered := make([]string, 0) + for _, metric := range *metrics { + if matchMetricName(filterPattern, metric) { + filtered = append(filtered, metric) + } + } + resp.Metrics = filtered + } + } + output, err := formatter.FormatOutput(resp, formatter.OutputFormat(outputFormat)) if err != nil { return err diff --git a/cmd/metrics_test.go b/cmd/metrics_test.go index 0046a39b..dda66a05 100644 --- a/cmd/metrics_test.go +++ b/cmd/metrics_test.go @@ -207,21 +207,38 @@ func TestRunMetricsList(t *testing.T) { defer cleanup() tests := []struct { - name string - filter string - wantErr bool + name string + filter string + tagFilterValue string + wantErr bool wantErrContains string }{ { - name: "no filter", - filter: "", - wantErr: true, + name: "no filter", + filter: "", + tagFilterValue: "", + wantErr: true, wantErrContains: "mock client", }, { - name: "with filter", - filter: "system.*", - wantErr: true, + name: "with name filter", + filter: "system.*", + tagFilterValue: "", + wantErr: true, + wantErrContains: "mock client", + }, + { + name: "with tag filter", + filter: "", + tagFilterValue: "env:prod", + wantErr: true, + wantErrContains: "mock client", + }, + { + name: "with both filters", + filter: "system.*", + tagFilterValue: "env:prod", + wantErr: true, wantErrContains: "mock client", }, } @@ -229,6 +246,7 @@ func TestRunMetricsList(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { filterPattern = tt.filter + tagFilter = tt.tagFilterValue var buf bytes.Buffer outputWriter = &buf @@ -247,6 +265,210 @@ func TestRunMetricsList(t *testing.T) { } } +func TestMatchMetricName(t *testing.T) { + tests := []struct { + name string + pattern string + metric string + want bool + }{ + // Empty pattern + { + name: "empty pattern matches all", + pattern: "", + metric: "system.cpu.user", + want: true, + }, + // Exact matches + { + name: "exact match", + pattern: "system.cpu.user", + metric: "system.cpu.user", + want: true, + }, + { + name: "exact mismatch", + pattern: "system.cpu.user", + metric: "system.cpu.system", + want: false, + }, + // Prefix wildcards + { + name: "prefix wildcard matches", + pattern: "system.*", + metric: "system.cpu.user", + want: true, + }, + { + name: "prefix wildcard matches all system metrics", + pattern: "system.*", + metric: "system.mem.used", + want: true, + }, + { + name: "prefix wildcard no match", + pattern: "system.*", + metric: "custom.cpu.user", + want: false, + }, + // Suffix wildcards + { + name: "suffix wildcard matches", + pattern: "*.cpu.user", + metric: "system.cpu.user", + want: true, + }, + { + name: "suffix wildcard matches different prefix", + pattern: "*.cpu.user", + metric: "custom.cpu.user", + want: true, + }, + { + name: "suffix wildcard no match", + pattern: "*.cpu.user", + metric: "system.mem.used", + want: false, + }, + // Middle wildcards + { + name: "middle wildcard matches", + pattern: "system.*.user", + metric: "system.cpu.user", + want: true, + }, + { + name: "middle wildcard matches multiple segments", + pattern: "system.*.user", + metric: "system.foo.bar.user", + want: true, + }, + { + name: "middle wildcard no match", + pattern: "system.*.user", + metric: "system.cpu.system", + want: false, + }, + // Contains patterns + { + name: "contains pattern matches", + pattern: "*cpu*", + metric: "system.cpu.user", + want: true, + }, + { + name: "contains pattern matches middle", + pattern: "*request*", + metric: "app.request.count", + want: true, + }, + { + name: "contains pattern no match", + pattern: "*request*", + metric: "system.cpu.user", + want: false, + }, + // Question mark wildcards + { + name: "question mark matches single char", + pattern: "system.cpu.?ser", + metric: "system.cpu.user", + want: true, + }, + { + name: "question mark matches any char", + pattern: "system.cpu.?ser", + metric: "system.cpu.aser", + want: true, + }, + { + name: "question mark no match multiple chars", + pattern: "system.cpu.?ser", + metric: "system.cpu.abser", + want: false, + }, + { + name: "question mark no match too short", + pattern: "system.cpu.?ser", + metric: "system.cpu.ser", + want: false, + }, + // Complex patterns + { + name: "multiple wildcards", + pattern: "*.cpu.*", + metric: "system.cpu.user", + want: true, + }, + { + name: "multiple wildcards different segments", + pattern: "*.cpu.*", + metric: "custom.app.cpu.load", + want: true, + }, + { + name: "multiple wildcards no match", + pattern: "*.cpu.*", + metric: "system.mem.used", + want: false, + }, + // Edge cases + { + name: "only wildcard matches all", + pattern: "*", + metric: "any.metric.name", + want: true, + }, + { + name: "trailing wildcard", + pattern: "system.cpu.*", + metric: "system.cpu.user", + want: true, + }, + { + name: "leading wildcard", + pattern: "*.user", + metric: "system.cpu.user", + want: true, + }, + // Real-world examples from the issue + { + name: "kafka_index prefix", + pattern: "kafka_index*", + metric: "kafka_index.fetch.latency", + want: true, + }, + { + name: "kafka_index wildcard", + pattern: "*kafka_index*", + metric: "my.kafka_index.metric", + want: true, + }, + { + name: "system.cpu exact", + pattern: "system.cpu", + metric: "system.cpu", + want: true, + }, + { + name: "system.cpu prefix", + pattern: "system.cpu*", + metric: "system.cpu.user", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := matchMetricName(tt.pattern, tt.metric) + if got != tt.want { + t.Errorf("matchMetricName(%q, %q) = %v, want %v", + tt.pattern, tt.metric, got, tt.want) + } + }) + } +} + func TestRunMetricsMetadataGet(t *testing.T) { cleanup := setupMetricsTestClient(t) defer cleanup()