|
| 1 | +package commands |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "os" |
| 6 | + "strings" |
| 7 | + "time" |
| 8 | + |
| 9 | + "github.com/jongio/azd-app/cli/src/internal/azure" |
| 10 | + "github.com/jongio/azd-app/cli/src/internal/service" |
| 11 | +) |
| 12 | + |
| 13 | +func (e *logsExecutor) extractLogsWithContext(logs []service.LogEntry, levelFilter service.LogLevel, contextLines int) []LogEntryWithContext { |
| 14 | + if len(logs) == 0 || contextLines <= 0 { |
| 15 | + return nil |
| 16 | + } |
| 17 | + |
| 18 | + // Find indices of matching entries |
| 19 | + var matchIndices []int |
| 20 | + for i, entry := range logs { |
| 21 | + if entry.Level == levelFilter { |
| 22 | + matchIndices = append(matchIndices, i) |
| 23 | + } |
| 24 | + } |
| 25 | + |
| 26 | + if len(matchIndices) == 0 { |
| 27 | + return nil |
| 28 | + } |
| 29 | + |
| 30 | + // Build entries with context, handling overlapping ranges |
| 31 | + result := make([]LogEntryWithContext, 0, len(matchIndices)) |
| 32 | + usedIndices := make(map[int]bool) // Track indices already shown in context |
| 33 | + |
| 34 | + for _, matchIdx := range matchIndices { |
| 35 | + entry := logs[matchIdx] |
| 36 | + |
| 37 | + // Extract before context |
| 38 | + startBefore := matchIdx - contextLines |
| 39 | + if startBefore < 0 { |
| 40 | + startBefore = 0 |
| 41 | + } |
| 42 | + before := make([]string, 0, contextLines) |
| 43 | + for i := startBefore; i < matchIdx; i++ { |
| 44 | + // Skip if this line was already shown (to avoid duplicating context) |
| 45 | + if !usedIndices[i] { |
| 46 | + before = append(before, logs[i].Message) |
| 47 | + usedIndices[i] = true |
| 48 | + } |
| 49 | + } |
| 50 | + |
| 51 | + // Extract after context |
| 52 | + endAfter := matchIdx + contextLines + 1 |
| 53 | + if endAfter > len(logs) { |
| 54 | + endAfter = len(logs) |
| 55 | + } |
| 56 | + after := make([]string, 0, contextLines) |
| 57 | + for i := matchIdx + 1; i < endAfter; i++ { |
| 58 | + // Skip if this line was already shown (to avoid duplicating context) |
| 59 | + if !usedIndices[i] { |
| 60 | + after = append(after, logs[i].Message) |
| 61 | + usedIndices[i] = true |
| 62 | + } |
| 63 | + } |
| 64 | + |
| 65 | + // Mark the match itself as used |
| 66 | + usedIndices[matchIdx] = true |
| 67 | + |
| 68 | + // Build context only if we have any lines |
| 69 | + var ctx *LogContext |
| 70 | + if len(before) > 0 || len(after) > 0 { |
| 71 | + ctx = &LogContext{ |
| 72 | + Before: before, |
| 73 | + After: after, |
| 74 | + } |
| 75 | + } |
| 76 | + |
| 77 | + result = append(result, LogEntryWithContext{ |
| 78 | + Service: entry.Service, |
| 79 | + Message: entry.Message, |
| 80 | + Level: logLevelToString(entry.Level), |
| 81 | + Timestamp: entry.Timestamp, |
| 82 | + IsStderr: entry.IsStderr, |
| 83 | + Context: ctx, |
| 84 | + }) |
| 85 | + } |
| 86 | + |
| 87 | + return result |
| 88 | +} |
| 89 | + |
| 90 | +// logLevelToString converts a LogLevel to its string representation. |
| 91 | +func logLevelToString(level service.LogLevel) string { |
| 92 | + switch level { |
| 93 | + case service.LogLevelInfo: |
| 94 | + return "info" |
| 95 | + case service.LogLevelWarn: |
| 96 | + return logLevelWarn |
| 97 | + case service.LogLevelError: |
| 98 | + return statusError |
| 99 | + case service.LogLevelDebug: |
| 100 | + return logLevelDebug |
| 101 | + default: |
| 102 | + return "info" |
| 103 | + } |
| 104 | +} |
| 105 | + |
| 106 | +// convertAzureLogLevel maps azure.LogLevel to the shared service.LogLevel enum. |
| 107 | +func convertAzureLogLevel(level azure.LogLevel) service.LogLevel { |
| 108 | + switch level { |
| 109 | + case azure.LogLevelWarn: |
| 110 | + return service.LogLevelWarn |
| 111 | + case azure.LogLevelError: |
| 112 | + return service.LogLevelError |
| 113 | + case azure.LogLevelDebug: |
| 114 | + return service.LogLevelDebug |
| 115 | + case azure.LogLevelInfo: |
| 116 | + fallthrough |
| 117 | + default: |
| 118 | + return service.LogLevelInfo |
| 119 | + } |
| 120 | +} |
| 121 | + |
| 122 | +// buildLogFilterInternal creates a log filter from executor options and azure.yaml config. |
| 123 | +func (e *logsExecutor) buildLogFilterInternal(cwd string) (*service.LogFilter, error) { |
| 124 | + var customPatterns []string |
| 125 | + |
| 126 | + // Parse command-line exclude patterns |
| 127 | + if e.opts.exclude != "" { |
| 128 | + customPatterns = service.ParseExcludePatterns(e.opts.exclude) |
| 129 | + } |
| 130 | + |
| 131 | + // Try to load patterns from azure.yaml logs.filters section |
| 132 | + azureYaml, err := service.ParseAzureYaml(cwd) |
| 133 | + filterConfig := getFilterConfig(azureYaml, err) |
| 134 | + if filterConfig != nil { |
| 135 | + customPatterns = append(customPatterns, filterConfig.Exclude...) |
| 136 | + } |
| 137 | + |
| 138 | + // Determine if we should include built-in patterns |
| 139 | + // CLI --no-builtins flag controls this; azure.yaml always includes builtins |
| 140 | + includeBuiltins := !e.opts.noBuiltins |
| 141 | + |
| 142 | + // Build the filter |
| 143 | + if includeBuiltins { |
| 144 | + return service.NewLogFilterWithBuiltins(customPatterns) |
| 145 | + } |
| 146 | + return service.NewLogFilter(customPatterns) |
| 147 | +} |
| 148 | + |
| 149 | +// getOrCreateSignalChan gets or creates a signal channel with proper cleanup. |
| 150 | +// This avoids duplication and race conditions in signal handling setup. |
| 151 | + |
| 152 | +func (e *logsExecutor) shouldDisplayEntry(entry service.LogEntry, levelFilter service.LogLevel, logFilter *service.LogFilter) bool { |
| 153 | + // Filter by level |
| 154 | + if levelFilter != LogLevelAll && entry.Level != levelFilter { |
| 155 | + return false |
| 156 | + } |
| 157 | + |
| 158 | + // Filter by pattern |
| 159 | + if logFilter != nil && logFilter.ShouldFilter(entry.Message) { |
| 160 | + return false |
| 161 | + } |
| 162 | + |
| 163 | + return true |
| 164 | +} |
| 165 | + |
| 166 | +// followLogs subscribes to live log streams and displays them. |
| 167 | + |
| 168 | +func parseLogLevel(level string) service.LogLevel { |
| 169 | + switch strings.ToLower(level) { |
| 170 | + case "info": |
| 171 | + return service.LogLevelInfo |
| 172 | + case logLevelWarn, "warning": |
| 173 | + return service.LogLevelWarn |
| 174 | + case "error": |
| 175 | + return service.LogLevelError |
| 176 | + case logLevelDebug: |
| 177 | + return service.LogLevelDebug |
| 178 | + case "all": |
| 179 | + return LogLevelAll |
| 180 | + default: |
| 181 | + return LogLevelAll |
| 182 | + } |
| 183 | +} |
| 184 | + |
| 185 | +// filterLogsByLevel filters logs by level with pre-allocated capacity. |
| 186 | +func filterLogsByLevel(logs []service.LogEntry, level service.LogLevel) []service.LogEntry { |
| 187 | + if level == LogLevelAll { |
| 188 | + return logs |
| 189 | + } |
| 190 | + |
| 191 | + // Pre-allocate with estimated capacity based on typical match rate |
| 192 | + estimatedCap := len(logs) / filterCapacityEstimate |
| 193 | + if estimatedCap < 10 { |
| 194 | + estimatedCap = 10 |
| 195 | + } |
| 196 | + filtered := make([]service.LogEntry, 0, estimatedCap) |
| 197 | + for _, entry := range logs { |
| 198 | + if entry.Level == level { |
| 199 | + filtered = append(filtered, entry) |
| 200 | + } |
| 201 | + } |
| 202 | + return filtered |
| 203 | +} |
| 204 | + |
| 205 | +// buildLogFilter creates a log filter from options and azure.yaml config. |
| 206 | +// This is a test helper function that wraps buildLogFilterInternal. |
| 207 | +// |
| 208 | +// Deprecated: Use executor.buildLogFilterInternal directly in new code. |
| 209 | +func buildLogFilter(cwd string, exclude string, noBuiltins bool) (*service.LogFilter, error) { |
| 210 | + var customPatterns []string |
| 211 | + |
| 212 | + // Parse command-line exclude patterns |
| 213 | + if exclude != "" { |
| 214 | + customPatterns = service.ParseExcludePatterns(exclude) |
| 215 | + } |
| 216 | + |
| 217 | + // Try to load patterns from azure.yaml logs.filters section |
| 218 | + azureYaml, err := service.ParseAzureYaml(cwd) |
| 219 | + filterConfig := getFilterConfig(azureYaml, err) |
| 220 | + if filterConfig != nil { |
| 221 | + customPatterns = append(customPatterns, filterConfig.Exclude...) |
| 222 | + } |
| 223 | + |
| 224 | + // Determine if we should include built-in patterns |
| 225 | + // CLI --no-builtins flag controls this; azure.yaml always includes builtins |
| 226 | + includeBuiltins := !noBuiltins |
| 227 | + |
| 228 | + // Build the filter |
| 229 | + if includeBuiltins { |
| 230 | + return service.NewLogFilterWithBuiltins(customPatterns) |
| 231 | + } |
| 232 | + return service.NewLogFilter(customPatterns) |
| 233 | +} |
| 234 | + |
| 235 | +// getFilterConfig extracts the filter config from azure.yaml's logs section. |
| 236 | +func getFilterConfig(azureYaml *service.AzureYaml, err error) *service.LogFilterConfig { |
| 237 | + if err != nil || azureYaml == nil { |
| 238 | + return nil |
| 239 | + } |
| 240 | + return azureYaml.Logs.GetFilters() |
| 241 | +} |
| 242 | + |
| 243 | +// validateLogsOptions validates command-line flag values. |
| 244 | +func validateLogsOptions(opts *logsOptions) error { |
| 245 | + // Validate tail is positive |
| 246 | + if opts.tail < 0 { |
| 247 | + return fmt.Errorf("--tail must be a positive number, got %d", opts.tail) |
| 248 | + } |
| 249 | + if opts.tail > maxTailLines { |
| 250 | + // Log warning before capping |
| 251 | + fmt.Fprintf(os.Stderr, "Warning: --tail value %d exceeds maximum, capping at %d\n", opts.tail, maxTailLines) |
| 252 | + opts.tail = maxTailLines |
| 253 | + } |
| 254 | + |
| 255 | + // Validate format |
| 256 | + switch opts.format { |
| 257 | + case "text", jsonOutputVal: |
| 258 | + // Valid formats |
| 259 | + default: |
| 260 | + return fmt.Errorf("--format must be 'text' or 'json', got '%s'", opts.format) |
| 261 | + } |
| 262 | + |
| 263 | + // Validate level |
| 264 | + switch strings.ToLower(opts.level) { |
| 265 | + case "info", logLevelWarn, "warning", "error", logLevelDebug, "all": |
| 266 | + // Valid levels |
| 267 | + default: |
| 268 | + return fmt.Errorf("--level must be one of: info, warn, error, debug, all; got '%s'", opts.level) |
| 269 | + } |
| 270 | + |
| 271 | + // Validate context requires level to be set (not "all") |
| 272 | + if opts.contextLines > 0 { |
| 273 | + if strings.ToLower(opts.level) == "all" { |
| 274 | + return fmt.Errorf("--context requires --level to be set (info, warn, error, or debug)") |
| 275 | + } |
| 276 | + } |
| 277 | + |
| 278 | + // Clamp context to valid range (0-MaxContextLines) |
| 279 | + if opts.contextLines < 0 { |
| 280 | + opts.contextLines = 0 |
| 281 | + } |
| 282 | + if opts.contextLines > service.MaxContextLines { |
| 283 | + fmt.Fprintf(os.Stderr, "Warning: --context value %d exceeds maximum, capping at %d\n", opts.contextLines, service.MaxContextLines) |
| 284 | + opts.contextLines = service.MaxContextLines |
| 285 | + } |
| 286 | + |
| 287 | + // Validate since duration if provided |
| 288 | + if opts.since != "" { |
| 289 | + if _, err := time.ParseDuration(opts.since); err != nil { |
| 290 | + return fmt.Errorf("--since must be a valid duration (e.g., 5m, 1h), got '%s': %w", opts.since, err) |
| 291 | + } |
| 292 | + } |
| 293 | + |
| 294 | + // Validate source |
| 295 | + switch strings.ToLower(opts.source) { |
| 296 | + case string(LogSourceLocal), string(LogSourceAzure), string(LogSourceAll): |
| 297 | + // Valid sources - normalize to lowercase |
| 298 | + opts.source = strings.ToLower(opts.source) |
| 299 | + case "": |
| 300 | + // Default to local if not specified |
| 301 | + opts.source = string(LogSourceLocal) |
| 302 | + default: |
| 303 | + return fmt.Errorf("--source must be 'local', 'azure', or 'all'; got '%s'", opts.source) |
| 304 | + } |
| 305 | + |
| 306 | + return nil |
| 307 | +} |
0 commit comments