diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 0530246..ab12589 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -50,10 +50,10 @@ func main() { ctx, combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout, *combinedConfig.Concurrency, combinedConfig.Requests, combinedConfig.Duration, - *combinedConfig.Quiet, *combinedConfig.Insecure, combinedConfig.Params, combinedConfig.Headers, + *combinedConfig.Progress == config.ConfigProgressTypeBar, *combinedConfig.Insecure, combinedConfig.Params, combinedConfig.Headers, combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values, *combinedConfig.Output != config.ConfigOutputTypeNone, - *combinedConfig.DryRun, + *combinedConfig.DryRun, *combinedConfig.LogLevel, *combinedConfig.LogFile, combinedConfig.Lua, combinedConfig.Js, ) _ = utilsErr.MustHandle(err, diff --git a/docs/configuration.md b/docs/configuration.md index cb56d28..5258d0f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -26,7 +26,9 @@ Use `-s` or `--show-config` to see the final merged configuration before sending | [Concurrency](#concurrency) | `concurrency`
(number) | `-concurrency` / `-c`
(number) | `SARIN_CONCURRENCY`
(number) | `1` | Number of concurrent workers | | [Requests](#requests) | `requests`
(number) | `-requests` / `-r`
(number) | `SARIN_REQUESTS`
(number) | - | Total requests to send | | [Duration](#duration) | `duration`
(duration) | `-duration` / `-d`
(duration) | `SARIN_DURATION`
(duration) | - | Test duration | -| [Quiet](#quiet) | `quiet`
(boolean) | `-quiet` / `-q`
(boolean) | `SARIN_QUIET`
(boolean) | `false` | Hide progress bar and logs | +| [Log Level](#log-level) | `logLevel`
(string) | `-log-level` / `-l`
(string) | `SARIN_LOG_LEVEL`
(string) | `error` | Runtime log levels to emit | +| [Log File](#log-file) | `logFile`
(string) | `-log-file` / `-w`
(string) | `SARIN_LOG_FILE`
(string) | - | Write runtime logs to a file | +| [Progress](#progress) | `progress`
(string) | `-progress` / `-p`
(string) | `SARIN_PROGRESS`
(string) | `bar` | Progress display (bar/none) | | [Output](#output) | `output`
(string) | `-output` / `-o`
(string) | `SARIN_OUTPUT`
(string) | `table` | Output format for stats | | [Dry Run](#dry-run) | `dryRun`
(boolean) | `-dry-run` / `-z`
(boolean) | `SARIN_DRY_RUN`
(boolean) | `false` | Generate without sending | | [Insecure](#insecure) | `insecure`
(boolean) | `-insecure` / `-I`
(boolean) | `SARIN_INSECURE`
(boolean) | `false` | Skip TLS verification | @@ -131,7 +133,7 @@ sarin -U "http://example.com/users/{{ fakeit_UUID }}" -r 1000 -c 10 ## Method -HTTP method(s). If multiple values are provided, Sarin starts at a random index and cycles through them in order. Once the cycle completes, it picks a new random starting point. Supports [templating](templating.md). +HTTP method(s). Defaults to `GET`. If multiple values are provided, Sarin starts at a random index and cycles through them in order. Once the cycle completes, it picks a new random starting point. Supports [templating](templating.md). **YAML example:** @@ -141,9 +143,9 @@ method: GET # OR method: - - GET - - POST - - PUT + - GET + - POST + - PUT ``` **CLI example:** @@ -160,7 +162,7 @@ SARIN_METHOD=GET ## Timeout -Request timeout. Must be greater than 0. +Request timeout. Must be greater than 0. Defaults to `10s`. Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h` @@ -168,7 +170,7 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h` ## Concurrency -Number of concurrent workers. Must be between 1 and 100,000,000. +Number of concurrent workers. Must be between 1 and 100,000,000. Defaults to `1`. ## Requests @@ -182,15 +184,34 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h` **Examples:** `1m30s`, `25s`, `1h` -## Quiet +## Log Level -Hide the progress bar and runtime logs. +Runtime log levels to emit, comma-separated. Valid levels: `info`, `error`. Defaults to `error`. + +- `error`: errors that occur while generating or sending a request +- `info`: every completed response + +Leave empty to disable logging entirely. + +**Examples:** `error` (only errors), `info` (only responses), `info,error` (both) + +## Log File + +Write runtime logs to this file instead of the terminal or stderr. The parent directory must exist. + +```sh +sarin -U http://example.com -r 1000 --log-file ./run.log +``` + +## Progress + +Progress display. Valid values: `bar` (default), `none`. Use `none` to hide the progress bar. ## Output Output format for response statistics. -Valid formats: `table`, `json`, `yaml`, `none` +Valid formats: `table` (default), `json`, `yaml`, `none` Using `none` disables output and reduces memory usage since response statistics are not stored. @@ -214,9 +235,9 @@ body: '{"product": "car"}' # OR body: - - '{"product": "car"}' - - '{"product": "phone"}' - - '{"product": "watch"}' + - '{"product": "car"}' + - '{"product": "phone"}' + - '{"product": "watch"}' ``` **CLI example:** @@ -241,19 +262,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v ```yaml params: - key1: value1 - key2: [value2, value3] # cycles between value2 and value3 + key1: value1 + key2: [value2, value3] # cycles between value2 and value3 # OR params: - - key1: value1 - - key2: [value2, value3] # cycles between value2 and value3 + - key1: value1 + - key2: [value2, value3] # cycles between value2 and value3 # To send both values in every request, use separate entries: params: - - key2: value2 - - key2: value3 # both sent in every request + - key2: value2 + - key2: value3 # both sent in every request ``` **CLI example:** @@ -278,19 +299,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v ```yaml headers: - key1: value1 - key2: [value2, value3] # cycles between value2 and value3 + key1: value1 + key2: [value2, value3] # cycles between value2 and value3 # OR headers: - - key1: value1 - - key2: [value2, value3] # cycles between value2 and value3 + - key1: value1 + - key2: [value2, value3] # cycles between value2 and value3 # To send both values in every request, use separate entries: headers: - - key2: value2 - - key2: value3 # both sent in every request + - key2: value2 + - key2: value3 # both sent in every request ``` **CLI example:** @@ -315,19 +336,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v ```yaml cookies: - key1: value1 - key2: [value2, value3] # cycles between value2 and value3 + key1: value1 + key2: [value2, value3] # cycles between value2 and value3 # OR cookies: - - key1: value1 - - key2: [value2, value3] # cycles between value2 and value3 + - key1: value1 + - key2: [value2, value3] # cycles between value2 and value3 # To send both values in every request, use separate entries: cookies: - - key2: value2 - - key2: value3 # both sent in every request + - key2: value2 + - key2: value3 # both sent in every request ``` **CLI example:** @@ -356,9 +377,9 @@ proxy: http://proxy1.com # OR proxy: - - http://proxy1.com - - socks5://proxy2.com - - socks5h://proxy3.com + - http://proxy1.com + - socks5://proxy2.com + - socks5h://proxy3.com ``` **CLI example:** @@ -387,9 +408,9 @@ values: "key=value" # OR values: | - key1=value1 - key2=value2 - key3=value3 + key1=value1 + key2=value2 + key3=value3 ``` **CLI example:** diff --git a/docs/examples.md b/docs/examples.md index 1095594..b30f139 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -13,6 +13,7 @@ This guide provides practical examples for common Sarin use cases. - [File Uploads](#file-uploads) - [Using Proxies](#using-proxies) - [Output Formats](#output-formats) +- [Runtime Logging](#runtime-logging) - [Docker Usage](#docker-usage) - [Dry Run Mode](#dry-run-mode) - [Show Configuration](#show-configuration) @@ -836,10 +837,10 @@ output: none -**Quiet mode (hide progress bar):** +**Hide the progress bar:** ```sh -sarin -U http://example.com -r 1000 -c 10 -q +sarin -U http://example.com -r 1000 -c 10 -p none ```
@@ -849,7 +850,42 @@ sarin -U http://example.com -r 1000 -c 10 -q url: http://example.com requests: 1000 concurrency: 10 -quiet: true +progress: none +``` + +
+ +## Runtime Logging + +`--log-level` selects which runtime logs Sarin emits (comma-separated `info` and `error`, default `error`). `error` covers request and generation errors, `info` covers every completed response (status, duration, headers, body). Logs appear in the progress log box on an interactive terminal, go to stderr when piped, or go to a file with `--log-file`. + +**Log responses and errors:** + +```sh +sarin -U http://example.com -r 1000 -c 10 -l info,error +``` + +**Write logs to a file (the progress bar stays on screen):** + +```sh +sarin -U http://example.com -r 1000 -c 10 -l info --log-file ./run.log +``` + +**Capture logs while keeping results on stdout:** + +```sh +sarin -U http://example.com -r 1000 -l info -o json > stats.json 2> run.log +``` + +
+YAML equivalent + +```yaml +url: http://example.com +requests: 1000 +concurrency: 10 +logLevel: info,error +logFile: ./run.log ```
diff --git a/docs/templating.md b/docs/templating.md index 336627b..1d2873e 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -183,19 +183,19 @@ body: '{{ body_FormData "image" "@https://example.com/photo.jpg" }}' # Mixed text fields and files body: | - {{ body_FormData - "title" "My Report" - "author" "John Doe" - "cover" "@/path/to/cover.jpg" - "document" "@/path/to/report.pdf" - }} + {{ body_FormData + "title" "My Report" + "author" "John Doe" + "cover" "@/path/to/cover.jpg" + "document" "@/path/to/report.pdf" + }} # Multiple files with same field name body: | - {{ body_FormData - "files" "@/path/to/file1.pdf" - "files" "@/path/to/file2.pdf" - }} + {{ body_FormData + "files" "@/path/to/file1.pdf" + "files" "@/path/to/file2.pdf" + }} # Escape @ for literal value (sends "@username") body: '{{ body_FormData "twitter" "@@username" }}' @@ -226,7 +226,7 @@ body: '{"file": "{{ file_Base64 "/path/to/document.pdf" }}", "filename": "docume body: '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}"}' # Combined with values for reuse -values: "FILE_DATA={{ file_Base64 \"/path/to/file.bin\" }}" +values: 'FILE_DATA={{ file_Base64 "/path/to/file.bin" }}' body: '{"data": "{{ .Values.FILE_DATA }}"}' ``` @@ -234,7 +234,7 @@ body: '{"data": "{{ .Values.FILE_DATA }}"}' Captcha functions solve a captcha challenge through a third-party solving service and return the resulting token, which can then be embedded directly into a request. They are intended for load testing endpoints protected by reCAPTCHA, hCaptcha, or Cloudflare Turnstile. -The functions are organized by service: `twocaptcha_*`, `anticaptcha_*`, and `capsolver_*`. Each accepts the API key as the first argument so no global configuration is required — bring your own key and use any of the supported services per template. +The functions are organized by service: `twocaptcha_*`, `anticaptcha_*`, and `capsolver_*`. Each accepts the API key as the first argument so no global configuration is required. Bring your own key and use any of the supported services per template. > **Important: performance and cost:** > diff --git a/internal/config/cli.go b/internal/config/cli.go index 0dd5895..a94b09c 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -27,7 +27,9 @@ Flags: -c, -concurrency uint Number of concurrent requests (default %d) -r, -requests uint Number of total requests -d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h) - -q, -quiet bool Hide the progress bar and runtime logs (default %v) + -l, -log-level string Runtime log levels to emit, comma-separated (possible values: info, error) (default %s) + -w, -log-file string Write runtime logs to this file instead of the terminal/stderr + -p, -progress string Progress display (possible values: bar, none) (default '%v') -o, -output string Output format (possible values: table, json, yaml, none) (default '%v') -z, -dry-run bool Run without sending requests (default %v) @@ -88,7 +90,9 @@ func (parser ConfigCLIParser) Parse() (*Config, error) { concurrency uint requestCount uint64 duration time.Duration - quiet bool + logLevel string + logFile string + progress string output string dryRun bool @@ -127,8 +131,14 @@ func (parser ConfigCLIParser) Parse() (*Config, error) { flagSet.DurationVar(&duration, "duration", 0, "Maximum duration for the test") flagSet.DurationVar(&duration, "d", 0, "Maximum duration for the test") - flagSet.BoolVar(&quiet, "quiet", false, "Hide the progress bar and runtime logs") - flagSet.BoolVar(&quiet, "q", false, "Hide the progress bar and runtime logs") + flagSet.StringVar(&logLevel, "log-level", "", "Runtime log levels to emit, comma-separated (possible values: info, error)") + flagSet.StringVar(&logLevel, "l", "", "Runtime log levels to emit, comma-separated (possible values: info, error)") + + flagSet.StringVar(&logFile, "log-file", "", "Write runtime logs to this file instead of the terminal/stderr") + flagSet.StringVar(&logFile, "w", "", "Write runtime logs to this file instead of the terminal/stderr") + + flagSet.StringVar(&progress, "progress", "", "Progress display (possible values: bar, none)") + flagSet.StringVar(&progress, "p", "", "Progress display (possible values: bar, none)") flagSet.StringVar(&output, "output", "", "Output format (possible values: table, json, yaml, none)") flagSet.StringVar(&output, "o", "", "Output format (possible values: table, json, yaml, none)") @@ -205,8 +215,12 @@ func (parser ConfigCLIParser) Parse() (*Config, error) { config.Requests = new(requestCount) case "duration", "d": config.Duration = new(duration) - case "quiet", "q": - config.Quiet = new(quiet) + case "log-level", "l": + config.LogLevel = new(logLevel) + case "log-file", "w": + config.LogFile = new(logFile) + case "progress", "p": + config.Progress = new(ConfigProgressType(progress)) case "output", "o": config.Output = new(ConfigOutputType(output)) case "dry-run", "z": @@ -265,7 +279,8 @@ func (parser ConfigCLIParser) PrintHelp() { cliUsageText+"\n", Defaults.ShowConfig, Defaults.Concurrency, - Defaults.Quiet, + Defaults.LogLevel, + Defaults.Progress, Defaults.Output, Defaults.DryRun, diff --git a/internal/config/config.go b/internal/config/config.go index a266f9e..2694622 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "fmt" "net/url" "os" + "path/filepath" "slices" "strconv" "strings" @@ -17,6 +18,7 @@ import ( "github.com/charmbracelet/glamour/styles" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/term" + "go.aykhans.me/sarin/internal/sarin" "go.aykhans.me/sarin/internal/script" "go.aykhans.me/sarin/internal/types" "go.aykhans.me/sarin/internal/version" @@ -31,25 +33,28 @@ var Defaults = struct { RequestTimeout time.Duration Concurrency uint ShowConfig bool - Quiet bool + Progress ConfigProgressType Insecure bool Output ConfigOutputType DryRun bool + LogLevel string }{ UserAgent: "Sarin/" + version.Version, Method: "GET", RequestTimeout: time.Second * 10, Concurrency: 1, ShowConfig: false, - Quiet: false, + Progress: ConfigProgressTypeBar, Insecure: false, Output: ConfigOutputTypeTable, DryRun: false, + LogLevel: "error", } var ( ValidProxySchemes = []string{"http", "https", "socks5", "socks5h"} ValidRequestURLSchemes = []string{"http", "https"} + ValidLogLevels = []string{"info", "error"} ) var ( @@ -70,27 +75,36 @@ var ( ConfigOutputTypeNone ConfigOutputType = "none" ) +type ConfigProgressType string + +var ( + ConfigProgressTypeBar ConfigProgressType = "bar" + ConfigProgressTypeNone ConfigProgressType = "none" +) + type Config struct { - ShowConfig *bool `yaml:"showConfig,omitempty"` - Files []types.ConfigFile `yaml:"files,omitempty"` - Methods []string `yaml:"methods,omitempty"` - URL *url.URL `yaml:"url,omitempty"` - Timeout *time.Duration `yaml:"timeout,omitempty"` - Concurrency *uint `yaml:"concurrency,omitempty"` - Requests *uint64 `yaml:"requests,omitempty"` - Duration *time.Duration `yaml:"duration,omitempty"` - Quiet *bool `yaml:"quiet,omitempty"` - Output *ConfigOutputType `yaml:"output,omitempty"` - Insecure *bool `yaml:"insecure,omitempty"` - DryRun *bool `yaml:"dryRun,omitempty"` - Params types.Params `yaml:"params,omitempty"` - Headers types.Headers `yaml:"headers,omitempty"` - Cookies types.Cookies `yaml:"cookies,omitempty"` - Bodies []string `yaml:"bodies,omitempty"` - Proxies types.Proxies `yaml:"proxies,omitempty"` - Values []string `yaml:"values,omitempty"` - Lua []string `yaml:"lua,omitempty"` - Js []string `yaml:"js,omitempty"` + ShowConfig *bool `yaml:"showConfig,omitempty"` + Files []types.ConfigFile `yaml:"files,omitempty"` + Methods []string `yaml:"methods,omitempty"` + URL *url.URL `yaml:"url,omitempty"` + Timeout *time.Duration `yaml:"timeout,omitempty"` + Concurrency *uint `yaml:"concurrency,omitempty"` + Requests *uint64 `yaml:"requests,omitempty"` + Duration *time.Duration `yaml:"duration,omitempty"` + Progress *ConfigProgressType `yaml:"progress,omitempty"` + Output *ConfigOutputType `yaml:"output,omitempty"` + Insecure *bool `yaml:"insecure,omitempty"` + DryRun *bool `yaml:"dryRun,omitempty"` + Params types.Params `yaml:"params,omitempty"` + Headers types.Headers `yaml:"headers,omitempty"` + Cookies types.Cookies `yaml:"cookies,omitempty"` + Bodies []string `yaml:"bodies,omitempty"` + Proxies types.Proxies `yaml:"proxies,omitempty"` + Values []string `yaml:"values,omitempty"` + Lua []string `yaml:"lua,omitempty"` + Js []string `yaml:"js,omitempty"` + LogLevel *string `yaml:"logLevel,omitempty"` + LogFile *string `yaml:"logFile,omitempty"` } func (config Config) MarshalYAML() (any, error) { @@ -173,8 +187,8 @@ func (config Config) MarshalYAML() (any, error) { if config.Duration != nil { addField(content, "duration", toNode(*config.Duration), "") } - if config.Quiet != nil { - addField(content, "quiet", toNode(*config.Quiet), "") + if config.Progress != nil { + addField(content, "progress", toNode(string(*config.Progress)), "") } if config.Output != nil { addField(content, "output", toNode(string(*config.Output)), "") @@ -221,6 +235,13 @@ func (config Config) MarshalYAML() (any, error) { addStringSlice(content, "values", config.Values, false) addStringSlice(content, "lua", config.Lua, false) addStringSlice(content, "js", config.Js, false) + if config.LogLevel != nil { + addField(content, "logLevel", toNode(*config.LogLevel), "") + } + + if config.LogFile != nil { + addField(content, "logFile", toNode(*config.LogFile), "") + } return root, nil } @@ -295,8 +316,8 @@ func (config *Config) Merge(newConfig *Config) { if newConfig.ShowConfig != nil { config.ShowConfig = newConfig.ShowConfig } - if newConfig.Quiet != nil { - config.Quiet = newConfig.Quiet + if newConfig.Progress != nil { + config.Progress = newConfig.Progress } if newConfig.Output != nil { config.Output = newConfig.Output @@ -331,6 +352,12 @@ func (config *Config) Merge(newConfig *Config) { if len(newConfig.Js) != 0 { config.Js = append(config.Js, newConfig.Js...) } + if newConfig.LogLevel != nil { + config.LogLevel = newConfig.LogLevel + } + if newConfig.LogFile != nil { + config.LogFile = newConfig.LogFile + } } func (config *Config) SetDefaults() { @@ -361,8 +388,8 @@ func (config *Config) SetDefaults() { if config.ShowConfig == nil { config.ShowConfig = new(Defaults.ShowConfig) } - if config.Quiet == nil { - config.Quiet = new(Defaults.Quiet) + if config.Progress == nil { + config.Progress = new(Defaults.Progress) } if config.Insecure == nil { config.Insecure = new(Defaults.Insecure) @@ -377,6 +404,14 @@ func (config *Config) SetDefaults() { if config.Output == nil { config.Output = new(Defaults.Output) } + + if config.LogLevel == nil { + config.LogLevel = new(Defaults.LogLevel) + } + + if config.LogFile == nil { + config.LogFile = new("") + } } // Validate validates the config fields. @@ -426,8 +461,21 @@ func (config Config) Validate() error { validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required"))) } - if config.Quiet == nil { - validationErrors = append(validationErrors, types.NewFieldValidationError("Quiet", "", errors.New("quiet field is required"))) + if config.Progress == nil { + validationErrors = append(validationErrors, types.NewFieldValidationError("Progress", "", errors.New("progress field is required"))) + } else { + switch *config.Progress { + case ConfigProgressTypeBar, ConfigProgressTypeNone: + default: + validationErrors = append( + validationErrors, + types.NewFieldValidationError( + "Progress", + string(*config.Progress), + fmt.Errorf("progress must be one of: %s, %s", ConfigProgressTypeBar, ConfigProgressTypeNone), + ), + ) + } } if config.Output == nil { @@ -460,6 +508,36 @@ func (config Config) Validate() error { validationErrors = append(validationErrors, types.NewFieldValidationError("DryRun", "", errors.New("dryRun field is required"))) } + if config.LogLevel != nil { + for i, level := range sarin.SplitLogLevels(*config.LogLevel) { + if !slices.Contains(ValidLogLevels, level) { + validationErrors = append( + validationErrors, + types.NewFieldValidationError( + fmt.Sprintf("LogLevel[%d]", i), + level, + fmt.Errorf("log level must be one of: %s", strings.Join(ValidLogLevels, ", ")), + ), + ) + } + } + } + + if config.LogFile != nil && *config.LogFile != "" { + dir := filepath.Dir(*config.LogFile) + if info, err := os.Stat(dir); err != nil { + validationErrors = append( + validationErrors, + types.NewFieldValidationError("LogFile", *config.LogFile, fmt.Errorf("parent directory %q is not accessible", dir)), + ) + } else if !info.IsDir() { + validationErrors = append( + validationErrors, + types.NewFieldValidationError("LogFile", *config.LogFile, fmt.Errorf("parent path %q is not a directory", dir)), + ) + } + } + for i, proxy := range config.Proxies { if !slices.Contains(ValidProxySchemes, proxy.Scheme) { validationErrors = append( diff --git a/internal/config/env.go b/internal/config/env.go index b6fe0d6..73cd46e 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -49,74 +49,6 @@ func (parser ConfigENVParser) Parse() (*Config, error) { config.Files = append(config.Files, *types.ParseConfigFile(configFile)) } - if quiet := parser.getEnv("QUIET"); quiet != "" { - quietParsed, err := utilsParse.ParseString[bool](quiet) - if err != nil { - fieldParseErrors = append( - fieldParseErrors, - types.NewFieldParseError( - parser.getFullEnvName("QUIET"), - quiet, - errors.New("invalid value for boolean, expected 'true' or 'false'"), - ), - ) - } else { - config.Quiet = &quietParsed - } - } - - if output := parser.getEnv("OUTPUT"); output != "" { - config.Output = new(ConfigOutputType(output)) - } - - if insecure := parser.getEnv("INSECURE"); insecure != "" { - insecureParsed, err := utilsParse.ParseString[bool](insecure) - if err != nil { - fieldParseErrors = append( - fieldParseErrors, - types.NewFieldParseError( - parser.getFullEnvName("INSECURE"), - insecure, - errors.New("invalid value for boolean, expected 'true' or 'false'"), - ), - ) - } else { - config.Insecure = &insecureParsed - } - } - - if dryRun := parser.getEnv("DRY_RUN"); dryRun != "" { - dryRunParsed, err := utilsParse.ParseString[bool](dryRun) - if err != nil { - fieldParseErrors = append( - fieldParseErrors, - types.NewFieldParseError( - parser.getFullEnvName("DRY_RUN"), - dryRun, - errors.New("invalid value for boolean, expected 'true' or 'false'"), - ), - ) - } else { - config.DryRun = &dryRunParsed - } - } - - if method := parser.getEnv("METHOD"); method != "" { - config.Methods = []string{method} - } - - if urlEnv := parser.getEnv("URL"); urlEnv != "" { - urlEnvParsed, err := url.Parse(urlEnv) - if err != nil { - fieldParseErrors = append( - fieldParseErrors, - types.NewFieldParseError(parser.getFullEnvName("URL"), urlEnv, err), - ) - } else { - config.URL = urlEnvParsed - } - } - if concurrency := parser.getEnv("CONCURRENCY"); concurrency != "" { concurrencyParsed, err := utilsParse.ParseString[uint](concurrency) if err != nil { @@ -165,22 +97,58 @@ func (parser ConfigENVParser) Parse() (*Config, error) { } } - if timeout := parser.getEnv("TIMEOUT"); timeout != "" { - timeoutParsed, err := utilsParse.ParseString[time.Duration](timeout) + if logLevel := parser.getEnv("LOG_LEVEL"); logLevel != "" { + config.LogLevel = new(logLevel) + } + + if logFile := parser.getEnv("LOG_FILE"); logFile != "" { + config.LogFile = new(logFile) + } + + if progress := parser.getEnv("PROGRESS"); progress != "" { + config.Progress = new(ConfigProgressType(progress)) + } + + if output := parser.getEnv("OUTPUT"); output != "" { + config.Output = new(ConfigOutputType(output)) + } + + if dryRun := parser.getEnv("DRY_RUN"); dryRun != "" { + dryRunParsed, err := utilsParse.ParseString[bool](dryRun) if err != nil { fieldParseErrors = append( fieldParseErrors, types.NewFieldParseError( - parser.getFullEnvName("TIMEOUT"), - timeout, - errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"), + parser.getFullEnvName("DRY_RUN"), + dryRun, + errors.New("invalid value for boolean, expected 'true' or 'false'"), ), ) } else { - config.Timeout = &timeoutParsed + config.DryRun = &dryRunParsed } } + if urlEnv := parser.getEnv("URL"); urlEnv != "" { + urlEnvParsed, err := url.Parse(urlEnv) + if err != nil { + fieldParseErrors = append( + fieldParseErrors, + types.NewFieldParseError(parser.getFullEnvName("URL"), urlEnv, err), + ) + } else { + config.URL = urlEnvParsed + } + } + + if method := parser.getEnv("METHOD"); method != "" { + config.Methods = []string{method} + } + + if body := parser.getEnv("BODY"); body != "" { + config.Bodies = []string{body} + } + if param := parser.getEnv("PARAM"); param != "" { config.Params.Parse(param) } @@ -193,10 +161,6 @@ func (parser ConfigENVParser) Parse() (*Config, error) { config.Cookies.Parse(cookie) } - if body := parser.getEnv("BODY"); body != "" { - config.Bodies = []string{body} - } - if proxy := parser.getEnv("PROXY"); proxy != "" { err := config.Proxies.Parse(proxy) if err != nil { @@ -215,6 +179,38 @@ func (parser ConfigENVParser) Parse() (*Config, error) { config.Values = []string{values} } + if timeout := parser.getEnv("TIMEOUT"); timeout != "" { + timeoutParsed, err := utilsParse.ParseString[time.Duration](timeout) + if err != nil { + fieldParseErrors = append( + fieldParseErrors, + types.NewFieldParseError( + parser.getFullEnvName("TIMEOUT"), + timeout, + errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"), + ), + ) + } else { + config.Timeout = &timeoutParsed + } + } + + if insecure := parser.getEnv("INSECURE"); insecure != "" { + insecureParsed, err := utilsParse.ParseString[bool](insecure) + if err != nil { + fieldParseErrors = append( + fieldParseErrors, + types.NewFieldParseError( + parser.getFullEnvName("INSECURE"), + insecure, + errors.New("invalid value for boolean, expected 'true' or 'false'"), + ), + ) + } else { + config.Insecure = &insecureParsed + } + } + if lua := parser.getEnv("LUA"); lua != "" { config.Lua = []string{lua} } diff --git a/internal/config/file.go b/internal/config/file.go index 2468b90..d5c95f0 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -192,24 +192,26 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error { } type configYAML struct { + ShowConfig *bool `yaml:"showConfig"` ConfigFiles stringOrSliceField `yaml:"configFile"` - Method stringOrSliceField `yaml:"method"` - URL *string `yaml:"url"` - Timeout *time.Duration `yaml:"timeout"` Concurrency *uint `yaml:"concurrency"` RequestCount *uint64 `yaml:"requests"` Duration *time.Duration `yaml:"duration"` - Quiet *bool `yaml:"quiet"` + LogLevel *string `yaml:"logLevel"` + LogFile *string `yaml:"logFile"` + Progress *string `yaml:"progress"` Output *string `yaml:"output"` - Insecure *bool `yaml:"insecure"` - ShowConfig *bool `yaml:"showConfig"` DryRun *bool `yaml:"dryRun"` + URL *string `yaml:"url"` + Method stringOrSliceField `yaml:"method"` + Bodies stringOrSliceField `yaml:"body"` Params keyValuesField `yaml:"params"` Headers keyValuesField `yaml:"headers"` Cookies keyValuesField `yaml:"cookies"` - Bodies stringOrSliceField `yaml:"body"` Proxies stringOrSliceField `yaml:"proxy"` Values stringOrSliceField `yaml:"values"` + Timeout *time.Duration `yaml:"timeout"` + Insecure *bool `yaml:"insecure"` Lua stringOrSliceField `yaml:"lua"` Js stringOrSliceField `yaml:"js"` } @@ -231,33 +233,7 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) { var fieldParseErrors []types.FieldParseError - config.Methods = append(config.Methods, parsedData.Method...) - config.Timeout = parsedData.Timeout - config.Concurrency = parsedData.Concurrency - config.Requests = parsedData.RequestCount - config.Duration = parsedData.Duration config.ShowConfig = parsedData.ShowConfig - config.Quiet = parsedData.Quiet - - if parsedData.Output != nil { - config.Output = new(ConfigOutputType(*parsedData.Output)) - } - - config.Insecure = parsedData.Insecure - config.DryRun = parsedData.DryRun - for _, kv := range parsedData.Params { - config.Params = append(config.Params, types.Param(kv)) - } - for _, kv := range parsedData.Headers { - config.Headers = append(config.Headers, types.Header(kv)) - } - for _, kv := range parsedData.Cookies { - config.Cookies = append(config.Cookies, types.Cookie(kv)) - } - config.Bodies = append(config.Bodies, parsedData.Bodies...) - config.Values = append(config.Values, parsedData.Values...) - config.Lua = append(config.Lua, parsedData.Lua...) - config.Js = append(config.Js, parsedData.Js...) if len(parsedData.ConfigFiles) > 0 { for _, configFile := range parsedData.ConfigFiles { @@ -265,6 +241,22 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) { } } + config.Concurrency = parsedData.Concurrency + config.Requests = parsedData.RequestCount + config.Duration = parsedData.Duration + config.LogLevel = parsedData.LogLevel + config.LogFile = parsedData.LogFile + + if parsedData.Progress != nil { + config.Progress = new(ConfigProgressType(*parsedData.Progress)) + } + + if parsedData.Output != nil { + config.Output = new(ConfigOutputType(*parsedData.Output)) + } + + config.DryRun = parsedData.DryRun + if parsedData.URL != nil { urlParsed, err := url.Parse(*parsedData.URL) if err != nil { @@ -274,6 +266,18 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) { } } + config.Methods = append(config.Methods, parsedData.Method...) + config.Bodies = append(config.Bodies, parsedData.Bodies...) + for _, kv := range parsedData.Params { + config.Params = append(config.Params, types.Param(kv)) + } + for _, kv := range parsedData.Headers { + config.Headers = append(config.Headers, types.Header(kv)) + } + for _, kv := range parsedData.Cookies { + config.Cookies = append(config.Cookies, types.Cookie(kv)) + } + for i, proxy := range parsedData.Proxies { err := config.Proxies.Parse(proxy) if err != nil { @@ -284,6 +288,12 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) { } } + config.Values = append(config.Values, parsedData.Values...) + config.Timeout = parsedData.Timeout + config.Insecure = parsedData.Insecure + config.Lua = append(config.Lua, parsedData.Lua...) + config.Js = append(config.Js, parsedData.Js...) + if len(fieldParseErrors) > 0 { return nil, types.NewFieldParseErrors(fieldParseErrors) } diff --git a/internal/sarin/runner.go b/internal/sarin/runner.go index 8a71817..c704620 100644 --- a/internal/sarin/runner.go +++ b/internal/sarin/runner.go @@ -2,8 +2,13 @@ package sarin import ( "context" + "encoding/json" + "fmt" + "io" + "log" "net/url" "os" + "strings" "sync" "sync/atomic" "time" @@ -14,20 +19,153 @@ import ( "go.aykhans.me/sarin/internal/types" ) -type runtimeMessageLevel uint8 +type runtimeLogLevel uint8 const ( - runtimeMessageLevelWarning runtimeMessageLevel = iota - runtimeMessageLevelError + runtimeLogLevelInfo runtimeLogLevel = iota + runtimeLogLevelError ) -type runtimeMessage struct { +type runtimeLog struct { timestamp time.Time - level runtimeMessageLevel + level runtimeLogLevel text string } -type messageSender func(level runtimeMessageLevel, text string) +type runtimeLogger func(level runtimeLogLevel, text string) + +// SplitLogLevels parses a comma-separated log-level string into a deduplicated +// slice of level tokens (surrounding whitespace trimmed, empties dropped). Level +// names are matched case-sensitively, like the other enum options. +func SplitLogLevels(levels string) []string { + var out []string + seen := make(map[string]bool) + for part := range strings.SplitSeq(levels, ",") { + token := strings.TrimSpace(part) + if token == "" || seen[token] { + continue + } + seen[token] = true + out = append(out, token) + } + return out +} + +// respLogger logs a single completed response. +type respLogger func(duration time.Duration, resp *fasthttp.Response) + +func noopLog(runtimeLogLevel, string) {} +func noopRespLog(time.Duration, *fasthttp.Response) {} + +// gateSendLog wraps emit with level filtering decided once from the enabled +// levels. It stays general (any level filters correctly) while avoiding a +// per-log check when both levels are on and any work at all when both are off. +func gateSendLog(logInfo, logError bool, emit func(level runtimeLogLevel, text string)) runtimeLogger { + switch { + case logInfo && logError: + return emit + case logInfo: + return func(level runtimeLogLevel, text string) { + if level == runtimeLogLevelInfo { + emit(level, text) + } + } + case logError: + return func(level runtimeLogLevel, text string) { + if level == runtimeLogLevelError { + emit(level, text) + } + } + default: + return noopLog + } +} + +// respBodySnippetLen bounds how many bytes of the body the compact (TUI) +// rendering shows. +const respBodySnippetLen = 100 + +// formatRuntimeLogLine renders a runtime log as a plain (unstyled) line for +// stderr output. ANSI styling is left to the TUI. +func formatRuntimeLogLine(timestamp time.Time, level runtimeLogLevel, text string) string { + levelStr := "ERROR" + if level == runtimeLogLevelInfo { + levelStr = "INFO" + } + return "[" + timestamp.Format("15:04:05") + "] " + levelStr + ": " + text +} + +// respToLog renders a response as a compact one-line summary for the TUI log box +// (the "[time] INFO:" prefix is added by the TUI). +func respToLog(duration time.Duration, resp *fasthttp.Response) string { + var sb strings.Builder + sb.WriteString(statusCodeToString(resp.StatusCode())) + sb.WriteString(" ") + sb.WriteString(Duration(duration).String()) + if snippet := bodySnippet(resp.Body(), respBodySnippetLen); snippet != "" { + sb.WriteString(" | ") + sb.WriteString(snippet) + } + return sb.String() +} + +type respLogEntry struct { + Status int `json:"status"` + Duration string `json:"duration"` + Headers map[string][]string `json:"headers,omitempty"` + Body string `json:"body,omitempty"` +} + +// respToLogJSON renders a response as a single, self-contained JSON line so the +// stderr stream stays valid NDJSON (pipeable to jq or any consumer). +func respToLogJSON(duration time.Duration, resp *fasthttp.Response) string { + entry := respLogEntry{ + Status: resp.StatusCode(), + Duration: Duration(duration).String(), + Headers: collectRespHeaders(resp), + Body: string(resp.Body()), + } + data, err := json.Marshal(entry) + if err != nil { + return `{"error":"failed to marshal response log"}` + } + return string(data) +} + +func collectRespHeaders(resp *fasthttp.Response) map[string][]string { + headers := make(map[string][]string) + for key, value := range resp.Header.All() { + k := string(key) + headers[k] = append(headers[k], string(value)) + } + return headers +} + +// bodySnippet returns the first maxLen bytes of body collapsed onto a single +// line (control characters replaced with spaces), with an ellipsis when truncated. +func bodySnippet(body []byte, maxLen int) string { + if len(body) == 0 { + return "" + } + + truncated := false + if len(body) > maxLen { + body = body[:maxLen] + truncated = true + } + + s := strings.Map(func(r rune) rune { + if r == '\n' || r == '\r' || r == '\t' { + return ' ' + } + return r + }, string(body)) + + if truncated { + s += "..." + } + return s +} type sarin struct { workers uint @@ -40,11 +178,14 @@ type sarin struct { totalRequests *uint64 totalDuration *time.Duration timeout time.Duration - quiet bool + showProgress bool skipCertVerify bool values []string collectStats bool dryRun bool + logInfo bool + logError bool + logFile string hostClients []*fasthttp.HostClient responses *SarinResponseData @@ -65,7 +206,7 @@ func NewSarin( workers uint, totalRequests *uint64, totalDuration *time.Duration, - quiet bool, + showProgress bool, skipCertVerify bool, params types.Params, headers types.Headers, @@ -75,6 +216,8 @@ func NewSarin( values []string, collectStats bool, dryRun bool, + logLevel string, + logFile string, luaScripts []string, jsScripts []string, ) (*sarin, error) { @@ -82,6 +225,17 @@ func NewSarin( workers = 1 } + // Resolve which log levels are enabled once, up front. + var logInfo, logError bool + for _, level := range SplitLogLevels(logLevel) { + switch level { + case "info": + logInfo = true + case "error": + logError = true + } + } + hostClients, err := newHostClients(ctx, timeout, proxies, workers, requestURL, skipCertVerify) if err != nil { return nil, err @@ -111,11 +265,14 @@ func NewSarin( totalRequests: totalRequests, totalDuration: totalDuration, timeout: timeout, - quiet: quiet, + showProgress: showProgress, skipCertVerify: skipCertVerify, values: values, collectStats: collectStats, dryRun: dryRun, + logInfo: logInfo, + logError: logError, + logFile: logFile, hostClients: hostClients, fileCache: NewFileCache(time.Second * 10), scriptChain: scriptChain, @@ -128,73 +285,95 @@ func NewSarin( return srn, nil } -func (q sarin) GetResponses() *SarinResponseData { - return q.responses +func (s sarin) GetResponses() *SarinResponseData { + return s.responses } -func (q sarin) Start(ctx context.Context, stopCtrl *StopController) { +func (s sarin) Start(ctx context.Context, stopCtrl *StopController) { jobsCtx, jobsCancel := context.WithCancel(ctx) var workersWG sync.WaitGroup - jobsCh := make(chan struct{}, max(q.workers, 1)) + jobsCh := make(chan struct{}, max(s.workers, 1)) var counter atomic.Uint64 totalRequests := uint64(0) - if q.totalRequests != nil { - totalRequests = *q.totalRequests + if s.totalRequests != nil { + totalRequests = *s.totalRequests } - var streamCtx context.Context - var streamCancel context.CancelFunc - var streamCh chan struct{} - var messageChannel chan runtimeMessage - var sendMessage messageSender + onTerminal := term.IsTerminal(os.Stdout.Fd()) + // The progress bar needs an interactive terminal to render. + showProgressBar := s.showProgress && onTerminal + // The bubbletea TUI hosts the bar and/or the live log box, so it runs whenever + // either has something to show: the bar, or logs that would land in the box + // (i.e. logs are enabled and not redirected to a file). + runTUI := showProgressBar || (onTerminal && (s.logInfo || s.logError) && s.logFile == "") - if !q.quiet && !term.IsTerminal(os.Stdout.Fd()) { - q.quiet = true + // Open the log file up front, before registering any defer, so a bad path + // exits cleanly. + var logFile *os.File + if s.logFile != "" { + f, err := os.OpenFile(s.logFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + fmt.Fprintln(os.Stderr, "failed to open log file "+s.logFile+": "+err.Error()) + os.Exit(1) + } + defer f.Close() //nolint:errcheck + logFile = f } - if q.quiet { - sendMessage = func(level runtimeMessageLevel, text string) {} - } else { + var ( + streamCtx context.Context + streamCancel context.CancelFunc + streamCh chan struct{} + tuiLogChannel chan runtimeLog + ) + if runTUI { streamCtx, streamCancel = context.WithCancel(context.Background()) defer streamCancel() streamCh = make(chan struct{}) - messageChannel = make(chan runtimeMessage, max(q.workers, 1)) - sendMessage = func(level runtimeMessageLevel, text string) { - messageChannel <- runtimeMessage{ - timestamp: time.Now(), - level: level, - text: text, - } - } + tuiLogChannel = make(chan runtimeLog, max(s.workers, 1)) + } + + // Route logs: to the file if given, else to the TUI log box while it runs, + // otherwise to stderr. + var ( + sendLog runtimeLogger + sendRespLog respLogger + ) + switch { + case logFile != nil: + sendLog, sendRespLog = s.newWriterLog(logFile) + case runTUI: + sendLog, sendRespLog = s.newChannelLog(tuiLogChannel) + default: + sendLog, sendRespLog = s.newWriterLog(os.Stderr) } // Start workers - q.startWorkers(&workersWG, jobsCh, q.hostClients, &counter, sendMessage) + s.startWorkers(&workersWG, jobsCh, s.hostClients, &counter, sendLog, sendRespLog) - if !q.quiet { - // Start streaming to terminal + if runTUI { //nolint:contextcheck // streamCtx must remain active until all workers complete to ensure all collected data is streamed - go q.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, messageChannel) + go s.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, tuiLogChannel, showProgressBar) } // Setup duration-based cancellation - q.setupDurationTimeout(ctx, jobsCancel) + s.setupDurationTimeout(ctx, jobsCancel) // Distribute jobs to workers. // This blocks until all jobs are sent or the context is canceled. - q.sendJobs(jobsCtx, jobsCh) + s.sendJobs(jobsCtx, jobsCh) // Close the jobs channel so workers stop after completing their current job close(jobsCh) // Wait until all workers stopped workersWG.Wait() - if messageChannel != nil { - close(messageChannel) + if tuiLogChannel != nil { + close(tuiLogChannel) } - if !q.quiet { + if runTUI { // Stop the progress streaming streamCancel() // Wait until progress streaming has completely stopped @@ -202,6 +381,45 @@ func (q sarin) Start(ctx context.Context, stopCtrl *StopController) { } } +// newWriterLog builds the loggers that write formatted lines to w (a log file or +// stderr). sendLog stays general (it filters by each log's level); sendRespLog +// only ever emits info, so its decision is baked once into a no-op when off. +func (s sarin) newWriterLog(w io.Writer) (runtimeLogger, respLogger) { + // log.Logger serializes writes with its own mutex, so concurrent workers + // won't interleave lines. + logger := log.New(w, "", 0) + + sendLog := gateSendLog(s.logInfo, s.logError, func(level runtimeLogLevel, text string) { + logger.Println(formatRuntimeLogLine(time.Now(), level, text)) + }) + + var sendRespLog respLogger = noopRespLog + if s.logInfo { + sendRespLog = func(duration time.Duration, resp *fasthttp.Response) { + logger.Println(formatRuntimeLogLine(time.Now(), runtimeLogLevelInfo, respToLogJSON(duration, resp))) + } + } + + return sendLog, sendRespLog +} + +// newChannelLog builds the loggers that feed the TUI log box through ch, with the +// same gating as newWriterLog. +func (s sarin) newChannelLog(ch chan<- runtimeLog) (runtimeLogger, respLogger) { + sendLog := gateSendLog(s.logInfo, s.logError, func(level runtimeLogLevel, text string) { + ch <- runtimeLog{timestamp: time.Now(), level: level, text: text} + }) + + var sendRespLog respLogger = noopRespLog + if s.logInfo { + sendRespLog = func(duration time.Duration, resp *fasthttp.Response) { + ch <- runtimeLog{timestamp: time.Now(), level: runtimeLogLevelInfo, text: respToLog(duration, resp)} + } + } + + return sendLog, sendRespLog +} + // newHostClients initializes HTTP clients for the given configuration. // It can return the following errors: // - types.ProxyDialError @@ -228,18 +446,18 @@ func newHostClients( ) } -func (q sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, hostClients []*fasthttp.HostClient, counter *atomic.Uint64, sendMessage messageSender) { - for range max(q.workers, 1) { +func (s sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, hostClients []*fasthttp.HostClient, counter *atomic.Uint64, sendLog runtimeLogger, sendRespLog respLogger) { + for range max(s.workers, 1) { wg.Go(func() { - q.Worker(jobs, NewHostClientGenerator(hostClients...), counter, sendMessage) + s.Worker(jobs, NewHostClientGenerator(hostClients...), counter, sendLog, sendRespLog) }) } } -func (q sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFunc) { - if q.totalDuration != nil { +func (s sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFunc) { + if s.totalDuration != nil { go func() { - timer := time.NewTimer(*q.totalDuration) + timer := time.NewTimer(*s.totalDuration) defer timer.Stop() select { case <-timer.C: @@ -251,9 +469,9 @@ func (q sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFu } } -func (q sarin) sendJobs(ctx context.Context, jobs chan<- struct{}) { - if q.totalRequests != nil && *q.totalRequests > 0 { - for range *q.totalRequests { +func (s sarin) sendJobs(ctx context.Context, jobs chan<- struct{}) { + if s.totalRequests != nil && *s.totalRequests > 0 { + for range *s.totalRequests { if ctx.Err() != nil { break } diff --git a/internal/sarin/tui.go b/internal/sarin/tui.go index 534d023..adbd2d9 100644 --- a/internal/sarin/tui.go +++ b/internal/sarin/tui.go @@ -16,24 +16,61 @@ import ( type tickMsg time.Time var ( - helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#d1d1d1")) - errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FC5B5B")).Bold(true) - warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD93D")).Bold(true) - messageChannelStyle = lipgloss.NewStyle(). - Border(lipgloss.ThickBorder(), false, false, false, true). - BorderForeground(lipgloss.Color("#757575")). - PaddingLeft(1). - Margin(1, 0, 0, 0). - Foreground(lipgloss.Color("#888888")) + helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#d1d1d1")) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FC5B5B")).Bold(true) + infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#5BC0FC")).Bold(true) + + errorLabel = errorStyle.Render("ERROR: ") + infoLabel = infoStyle.Render("INFO: ") + + logChannelStyle = lipgloss.NewStyle(). + Border(lipgloss.ThickBorder(), false, false, false, true). + BorderForeground(lipgloss.Color("#757575")). + PaddingLeft(1). + Margin(1, 0, 0, 0). + Foreground(lipgloss.Color("#888888")) ) +// renderRuntimeLog builds a single styled log line for the TUI log box. +func renderRuntimeLog(log runtimeLog) string { + label := errorLabel + if log.level == runtimeLogLevelInfo { + label = infoLabel + } + return "[" + log.timestamp.Format("15:04:05") + "] " + label + log.text +} + +// renderLogBox renders the styled log box, or "" when there are no lines. +func renderLogBox(logs []string) string { + var b strings.Builder + for i, line := range logs { + if len(line) > 0 { + b.WriteString(line) + if i < len(logs)-1 { + b.WriteString("\n") + } + } + } + if b.Len() == 0 { + return "" + } + return logChannelStyle.Render(b.String()) +} + +func helpLine(cancelling bool) string { + if cancelling { + return helpStyle.Render("Stopping... (Ctrl+C again to force)") + } + return helpStyle.Render("Press Ctrl+C to quit") +} + type progressModel struct { progress progress.Model startTime time.Time - messages []string + logs []string counter *atomic.Uint64 - current uint64 maxValue uint64 + showBar bool ctx context.Context //nolint:containedctx stop func() cancelling bool @@ -59,19 +96,8 @@ func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil - case runtimeMessage: - var msgBuilder strings.Builder - msgBuilder.WriteString("[") - msgBuilder.WriteString(msg.timestamp.Format("15:04:05")) - msgBuilder.WriteString("] ") - switch msg.level { - case runtimeMessageLevelError: - msgBuilder.WriteString(errorStyle.Render("ERROR: ")) - case runtimeMessageLevelWarning: - msgBuilder.WriteString(warningStyle.Render("WARNING: ")) - } - msgBuilder.WriteString(msg.text) - m.messages = append(m.messages[1:], msgBuilder.String()) + case runtimeLog: + m.logs = append(m.logs[1:], renderRuntimeLog(msg)) if m.ctx.Err() != nil { return m, tea.Quit } @@ -92,38 +118,27 @@ func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m progressModel) View() string { - var messagesBuilder strings.Builder - for i, msg := range m.messages { - if len(msg) > 0 { - messagesBuilder.WriteString(msg) - if i < len(m.messages)-1 { - messagesBuilder.WriteString("\n") - } - } + var b strings.Builder + if box := renderLogBox(m.logs); box != "" { + b.WriteString(box) + b.WriteString("\n") } - var finalBuilder strings.Builder - if messagesBuilder.Len() > 0 { - finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String())) - finalBuilder.WriteString("\n") + if m.showBar { + current := m.counter.Load() + b.WriteString("\n ") + b.WriteString(strconv.FormatUint(current, 10)) + b.WriteString("/") + b.WriteString(strconv.FormatUint(m.maxValue, 10)) + b.WriteString(" - ") + b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String()) + b.WriteString("\n ") + b.WriteString(m.progress.ViewAs(float64(current) / float64(m.maxValue))) } - m.current = m.counter.Load() - finalBuilder.WriteString("\n ") - finalBuilder.WriteString(strconv.FormatUint(m.current, 10)) - finalBuilder.WriteString("/") - finalBuilder.WriteString(strconv.FormatUint(m.maxValue, 10)) - finalBuilder.WriteString(" - ") - finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String()) - finalBuilder.WriteString("\n ") - finalBuilder.WriteString(m.progress.ViewAs(float64(m.current) / float64(m.maxValue))) - finalBuilder.WriteString("\n\n ") - if m.cancelling { - finalBuilder.WriteString(helpStyle.Render("Stopping... (Ctrl+C again to force)")) - } else { - finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit")) - } - return finalBuilder.String() + b.WriteString("\n\n ") + b.WriteString(helpLine(m.cancelling)) + return b.String() } func progressTickCmd() tea.Cmd { @@ -138,7 +153,8 @@ type infiniteProgressModel struct { spinner spinner.Model startTime time.Time counter *atomic.Uint64 - messages []string + logs []string + showBar bool ctx context.Context //nolint:containedctx quit bool stop func() @@ -158,19 +174,8 @@ func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil - case runtimeMessage: - var msgBuilder strings.Builder - msgBuilder.WriteString("[") - msgBuilder.WriteString(msg.timestamp.Format("15:04:05")) - msgBuilder.WriteString("] ") - switch msg.level { - case runtimeMessageLevelError: - msgBuilder.WriteString(errorStyle.Render("ERROR: ")) - case runtimeMessageLevelWarning: - msgBuilder.WriteString(warningStyle.Render("WARNING: ")) - } - msgBuilder.WriteString(msg.text) - m.messages = append(m.messages[1:], msgBuilder.String()) + case runtimeLog: + m.logs = append(m.logs[1:], renderRuntimeLog(msg)) if m.ctx.Err() != nil { m.quit = true return m, tea.Quit @@ -189,64 +194,60 @@ func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m infiniteProgressModel) View() string { - var messagesBuilder strings.Builder - for i, msg := range m.messages { - if len(msg) > 0 { - messagesBuilder.WriteString(msg) - if i < len(m.messages)-1 { - messagesBuilder.WriteString("\n") - } - } + var b strings.Builder + if box := renderLogBox(m.logs); box != "" { + b.WriteString(box) + b.WriteString("\n") } - var finalBuilder strings.Builder - if messagesBuilder.Len() > 0 { - finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String())) - finalBuilder.WriteString("\n") + // Without a spinner, the view is just the log box (plus help until quit). + if !m.showBar { + if !m.quit { + b.WriteString("\n\n ") + b.WriteString(helpLine(m.cancelling)) + } + return b.String() } if m.quit { - finalBuilder.WriteString("\n ") - finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10)) - finalBuilder.WriteString(" ") - finalBuilder.WriteString(infiniteProgressStyle.Render("∙∙∙∙∙")) - finalBuilder.WriteString(" ") - finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String()) - finalBuilder.WriteString("\n\n") + b.WriteString("\n ") + b.WriteString(strconv.FormatUint(m.counter.Load(), 10)) + b.WriteString(" ") + b.WriteString(infiniteProgressStyle.Render("∙∙∙∙∙")) + b.WriteString(" ") + b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String()) + b.WriteString("\n\n") } else { - finalBuilder.WriteString("\n ") - finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10)) - finalBuilder.WriteString(" ") - finalBuilder.WriteString(m.spinner.View()) - finalBuilder.WriteString(" ") - finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String()) - finalBuilder.WriteString("\n\n ") - if m.cancelling { - finalBuilder.WriteString(helpStyle.Render("Stopping... (Ctrl+C again to force)")) - } else { - finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit")) - } + b.WriteString("\n ") + b.WriteString(strconv.FormatUint(m.counter.Load(), 10)) + b.WriteString(" ") + b.WriteString(m.spinner.View()) + b.WriteString(" ") + b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String()) + b.WriteString("\n\n ") + b.WriteString(helpLine(m.cancelling)) } - return finalBuilder.String() + return b.String() } -func (q sarin) streamProgress( +func (s sarin) streamProgress( ctx context.Context, stopCtrl *StopController, done chan<- struct{}, total uint64, counter *atomic.Uint64, - messageChannel <-chan runtimeMessage, + logChannel <-chan runtimeLog, + showBar bool, ) { var program *tea.Program if total > 0 { model := progressModel{ progress: progress.New(progress.WithGradient("#151594", "#00D4FF")), startTime: time.Now(), - messages: make([]string, 8), + logs: make([]string, 8), counter: counter, - current: 0, maxValue: total, + showBar: showBar, ctx: ctx, stop: stopCtrl.Stop, } @@ -274,7 +275,8 @@ func (q sarin) streamProgress( ), startTime: time.Now(), counter: counter, - messages: make([]string, 8), + logs: make([]string, 8), + showBar: showBar, ctx: ctx, stop: stopCtrl.Stop, quit: false, @@ -287,7 +289,7 @@ func (q sarin) streamProgress( defer stopCtrl.AttachProgram(nil) go func() { - for msg := range messageChannel { + for msg := range logChannel { program.Send(msg) } }() diff --git a/internal/sarin/worker.go b/internal/sarin/worker.go index aabe550..ba97b2f 100644 --- a/internal/sarin/worker.go +++ b/internal/sarin/worker.go @@ -29,11 +29,12 @@ func statusCodeToString(code int) string { return strconv.Itoa(code) } -func (q sarin) Worker( +func (s sarin) Worker( jobs <-chan struct{}, hostClientGenerator HostClientGenerator, counter *atomic.Uint64, - sendMessage messageSender, + sendLog runtimeLogger, + sendRespLog respLogger, ) { req := fasthttp.AcquireRequest() resp := fasthttp.AcquireResponse() @@ -43,9 +44,9 @@ func (q sarin) Worker( // Create script transformer for this worker (engines are not thread-safe) // Scripts are pre-validated in NewSarin, so this should not fail var scriptTransformer *script.Transformer - if !q.scriptChain.IsEmpty() { + if !s.scriptChain.IsEmpty() { var err error - scriptTransformer, err = q.scriptChain.NewTransformer() + scriptTransformer, err = s.scriptChain.NewTransformer() if err != nil { panic(err) } @@ -53,79 +54,84 @@ func (q sarin) Worker( } requestGenerator, isDynamic := NewRequestGenerator( - q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values, q.fileCache, scriptTransformer, + s.methods, s.requestURL, s.params, s.headers, s.cookies, s.bodies, s.values, s.fileCache, scriptTransformer, ) - if q.dryRun { + if s.dryRun { switch { - case q.collectStats && isDynamic: - q.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage) - case q.collectStats && !isDynamic: - q.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage) - case !q.collectStats && isDynamic: - q.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage) + case s.collectStats && isDynamic: + s.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, counter, sendLog) + case s.collectStats && !isDynamic: + s.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendLog) + case !s.collectStats && isDynamic: + s.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, counter, sendLog) default: - q.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage) + s.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendLog) } } else { switch { - case q.collectStats && isDynamic: - q.workerStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage) - case q.collectStats && !isDynamic: - q.workerStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage) - case !q.collectStats && isDynamic: - q.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage) + case s.collectStats && isDynamic: + s.workerStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog) + case s.collectStats && !isDynamic: + s.workerStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog) + case !s.collectStats && isDynamic: + s.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog) default: - q.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage) + s.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog) } } } -func (q sarin) workerStatsWithDynamic( +func (s sarin) workerStatsWithDynamic( jobs <-chan struct{}, req *fasthttp.Request, resp *fasthttp.Response, requestGenerator RequestGenerator, hostClientGenerator HostClientGenerator, counter *atomic.Uint64, - sendMessage messageSender, + sendLog runtimeLogger, + sendRespLog respLogger, ) { for range jobs { req.Reset() resp.Reset() if err := requestGenerator(req); err != nil { - q.responses.Add(err.Error(), 0) - sendMessage(runtimeMessageLevelError, err.Error()) + s.responses.Add(err.Error(), 0) + sendLog(runtimeLogLevelError, err.Error()) counter.Add(1) continue } startTime := time.Now() - err := hostClientGenerator().DoTimeout(req, resp, q.timeout) + err := hostClientGenerator().DoTimeout(req, resp, s.timeout) + respDuration := time.Since(startTime) + if err != nil { - q.responses.Add(err.Error(), time.Since(startTime)) + s.responses.Add(err.Error(), respDuration) } else { - q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime)) + s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration) + sendRespLog(respDuration, resp) } counter.Add(1) } } -func (q sarin) workerStatsWithStatic( +func (s sarin) workerStatsWithStatic( jobs <-chan struct{}, req *fasthttp.Request, resp *fasthttp.Response, requestGenerator RequestGenerator, hostClientGenerator HostClientGenerator, counter *atomic.Uint64, - sendMessage messageSender, + sendLog runtimeLogger, + sendRespLog respLogger, ) { if err := requestGenerator(req); err != nil { // Static request generation failed - record all jobs as errors for range jobs { - q.responses.Add(err.Error(), 0) - sendMessage(runtimeMessageLevelError, err.Error()) + s.responses.Add(err.Error(), 0) + sendLog(runtimeLogLevelError, err.Error()) counter.Add(1) } return @@ -135,49 +141,57 @@ func (q sarin) workerStatsWithStatic( resp.Reset() startTime := time.Now() - err := hostClientGenerator().DoTimeout(req, resp, q.timeout) + err := hostClientGenerator().DoTimeout(req, resp, s.timeout) + respDuration := time.Since(startTime) if err != nil { - q.responses.Add(err.Error(), time.Since(startTime)) + s.responses.Add(err.Error(), respDuration) } else { - q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime)) + s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration) + sendRespLog(respDuration, resp) } counter.Add(1) } } -func (q sarin) workerNoStatsWithDynamic( +func (s sarin) workerNoStatsWithDynamic( jobs <-chan struct{}, req *fasthttp.Request, resp *fasthttp.Response, requestGenerator RequestGenerator, hostClientGenerator HostClientGenerator, counter *atomic.Uint64, - sendMessage messageSender, + sendLog runtimeLogger, + sendRespLog respLogger, ) { for range jobs { req.Reset() resp.Reset() if err := requestGenerator(req); err != nil { - sendMessage(runtimeMessageLevelError, err.Error()) + sendLog(runtimeLogLevelError, err.Error()) counter.Add(1) continue } - _ = hostClientGenerator().DoTimeout(req, resp, q.timeout) + startTime := time.Now() + err := hostClientGenerator().DoTimeout(req, resp, s.timeout) + if err == nil { + sendRespLog(time.Since(startTime), resp) + } counter.Add(1) } } -func (q sarin) workerNoStatsWithStatic( +func (s sarin) workerNoStatsWithStatic( jobs <-chan struct{}, req *fasthttp.Request, resp *fasthttp.Response, requestGenerator RequestGenerator, hostClientGenerator HostClientGenerator, counter *atomic.Uint64, - sendMessage messageSender, + sendLog runtimeLogger, + sendRespLog respLogger, ) { if err := requestGenerator(req); err != nil { - sendMessage(runtimeMessageLevelError, err.Error()) + sendLog(runtimeLogLevelError, err.Error()) // Static request generation failed - just count the jobs without sending for range jobs { @@ -188,80 +202,84 @@ func (q sarin) workerNoStatsWithStatic( for range jobs { resp.Reset() - _ = hostClientGenerator().DoTimeout(req, resp, q.timeout) + startTime := time.Now() + err := hostClientGenerator().DoTimeout(req, resp, s.timeout) + if err == nil { + sendRespLog(time.Since(startTime), resp) + } counter.Add(1) } } -func (q sarin) workerDryRunStatsWithDynamic( +func (s sarin) workerDryRunStatsWithDynamic( jobs <-chan struct{}, req *fasthttp.Request, requestGenerator RequestGenerator, counter *atomic.Uint64, - sendMessage messageSender, + sendLog runtimeLogger, ) { for range jobs { req.Reset() startTime := time.Now() if err := requestGenerator(req); err != nil { - q.responses.Add(err.Error(), time.Since(startTime)) - sendMessage(runtimeMessageLevelError, err.Error()) + s.responses.Add(err.Error(), time.Since(startTime)) + sendLog(runtimeLogLevelError, err.Error()) counter.Add(1) continue } - q.responses.Add(dryRunResponseKey, time.Since(startTime)) + s.responses.Add(dryRunResponseKey, time.Since(startTime)) counter.Add(1) } } -func (q sarin) workerDryRunStatsWithStatic( +func (s sarin) workerDryRunStatsWithStatic( jobs <-chan struct{}, req *fasthttp.Request, requestGenerator RequestGenerator, counter *atomic.Uint64, - sendMessage messageSender, + sendLog runtimeLogger, ) { if err := requestGenerator(req); err != nil { // Static request generation failed - record all jobs as errors for range jobs { - q.responses.Add(err.Error(), 0) - sendMessage(runtimeMessageLevelError, err.Error()) + s.responses.Add(err.Error(), 0) + sendLog(runtimeLogLevelError, err.Error()) counter.Add(1) } return } for range jobs { - q.responses.Add(dryRunResponseKey, 0) + s.responses.Add(dryRunResponseKey, 0) counter.Add(1) } } -func (q sarin) workerDryRunNoStatsWithDynamic( +func (s sarin) workerDryRunNoStatsWithDynamic( jobs <-chan struct{}, req *fasthttp.Request, requestGenerator RequestGenerator, counter *atomic.Uint64, - sendMessage messageSender, + sendLog runtimeLogger, ) { for range jobs { req.Reset() if err := requestGenerator(req); err != nil { - sendMessage(runtimeMessageLevelError, err.Error()) + sendLog(runtimeLogLevelError, err.Error()) } counter.Add(1) } } -func (q sarin) workerDryRunNoStatsWithStatic( +func (s sarin) workerDryRunNoStatsWithStatic( jobs <-chan struct{}, req *fasthttp.Request, requestGenerator RequestGenerator, counter *atomic.Uint64, - sendMessage messageSender, + sendLog runtimeLogger, ) { if err := requestGenerator(req); err != nil { - sendMessage(runtimeMessageLevelError, err.Error()) + sendLog(runtimeLogLevelError, err.Error()) } for range jobs {