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 {