From ee6d530f0dae9546783e38dfc478e707ff404366 Mon Sep 17 00:00:00 2001 From: Dmitry Romanov Date: Thu, 14 May 2026 13:44:49 +0700 Subject: [PATCH 1/4] http request action --- README.md | 2 +- _sidebar.md | 1 + cmd/file.d/file.d.go | 1 + e2e/start_work_test.go | 1 + go.mod | 6 +- go.sum | 12 +- plugin/README.md | 4 + plugin/action/README.md | 4 + plugin/action/http_request/README.idoc.md | 8 + plugin/action/http_request/README.md | 95 +++ plugin/action/http_request/http_request.go | 342 ++++++++++ .../action/http_request/http_request_test.go | 638 ++++++++++++++++++ 12 files changed, 1107 insertions(+), 7 deletions(-) create mode 100644 plugin/action/http_request/README.idoc.md create mode 100755 plugin/action/http_request/README.md create mode 100644 plugin/action/http_request/http_request.go create mode 100644 plugin/action/http_request/http_request_test.go diff --git a/README.md b/README.md index 0a2d215fe..ddd6ece47 100755 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ TBD: throughput on production servers. **Input**: [dmesg](plugin/input/dmesg/README.md), [fake](plugin/input/fake/README.md), [file](plugin/input/file/README.md), [http](plugin/input/http/README.md), [journalctl](plugin/input/journalctl/README.md), [k8s](plugin/input/k8s/README.md), [kafka](plugin/input/kafka/README.md), [socket](plugin/input/socket/README.md) -**Action**: [add_file_name](plugin/action/add_file_name/README.md), [add_host](plugin/action/add_host/README.md), [cardinality](plugin/action/cardinality/README.md), [convert_date](plugin/action/convert_date/README.md), [convert_log_level](plugin/action/convert_log_level/README.md), [convert_utf8_bytes](plugin/action/convert_utf8_bytes/README.md), [debug](plugin/action/debug/README.md), [decode](plugin/action/decode/README.md), [discard](plugin/action/discard/README.md), [flatten](plugin/action/flatten/README.md), [hash](plugin/action/hash/README.md), [join](plugin/action/join/README.md), [join_template](plugin/action/join_template/README.md), [json_decode](plugin/action/json_decode/README.md), [json_encode](plugin/action/json_encode/README.md), [json_extract](plugin/action/json_extract/README.md), [keep_fields](plugin/action/keep_fields/README.md), [mask](plugin/action/mask/README.md), [modify](plugin/action/modify/README.md), [move](plugin/action/move/README.md), [parse_es](plugin/action/parse_es/README.md), [parse_re2](plugin/action/parse_re2/README.md), [remove_fields](plugin/action/remove_fields/README.md), [rename](plugin/action/rename/README.md), [set_time](plugin/action/set_time/README.md), [split](plugin/action/split/README.md), [throttle](plugin/action/throttle/README.md) +**Action**: [add_file_name](plugin/action/add_file_name/README.md), [add_host](plugin/action/add_host/README.md), [cardinality](plugin/action/cardinality/README.md), [convert_date](plugin/action/convert_date/README.md), [convert_log_level](plugin/action/convert_log_level/README.md), [convert_utf8_bytes](plugin/action/convert_utf8_bytes/README.md), [debug](plugin/action/debug/README.md), [decode](plugin/action/decode/README.md), [discard](plugin/action/discard/README.md), [flatten](plugin/action/flatten/README.md), [hash](plugin/action/hash/README.md), [http_request](plugin/action/http_request/README.md), [join](plugin/action/join/README.md), [join_template](plugin/action/join_template/README.md), [json_decode](plugin/action/json_decode/README.md), [json_encode](plugin/action/json_encode/README.md), [json_extract](plugin/action/json_extract/README.md), [keep_fields](plugin/action/keep_fields/README.md), [mask](plugin/action/mask/README.md), [modify](plugin/action/modify/README.md), [move](plugin/action/move/README.md), [parse_es](plugin/action/parse_es/README.md), [parse_re2](plugin/action/parse_re2/README.md), [remove_fields](plugin/action/remove_fields/README.md), [rename](plugin/action/rename/README.md), [set_time](plugin/action/set_time/README.md), [split](plugin/action/split/README.md), [throttle](plugin/action/throttle/README.md) **Output**: [clickhouse](plugin/output/clickhouse/README.md), [devnull](plugin/output/devnull/README.md), [elasticsearch](plugin/output/elasticsearch/README.md), [file](plugin/output/file/README.md), [gelf](plugin/output/gelf/README.md), [http](plugin/output/http/README.md), [kafka](plugin/output/kafka/README.md), [loki](plugin/output/loki/README.md), [postgres](plugin/output/postgres/README.md), [s3](plugin/output/s3/README.md), [socket](plugin/output/socket/README.md), [splunk](plugin/output/splunk/README.md), [stdout](plugin/output/stdout/README.md) diff --git a/_sidebar.md b/_sidebar.md index 4a50551a9..1df8de1c9 100644 --- a/_sidebar.md +++ b/_sidebar.md @@ -34,6 +34,7 @@ - [discard](plugin/action/discard/README.md) - [flatten](plugin/action/flatten/README.md) - [hash](plugin/action/hash/README.md) + - [http_request](plugin/action/http_request/README.md) - [join](plugin/action/join/README.md) - [join_template](plugin/action/join_template/README.md) - [json_decode](plugin/action/json_decode/README.md) diff --git a/cmd/file.d/file.d.go b/cmd/file.d/file.d.go index 4cb8b58ca..e7b153312 100644 --- a/cmd/file.d/file.d.go +++ b/cmd/file.d/file.d.go @@ -28,6 +28,7 @@ import ( _ "github.com/ozontech/file.d/plugin/action/discard" _ "github.com/ozontech/file.d/plugin/action/flatten" _ "github.com/ozontech/file.d/plugin/action/hash" + _ "github.com/ozontech/file.d/plugin/action/http_request" _ "github.com/ozontech/file.d/plugin/action/join" _ "github.com/ozontech/file.d/plugin/action/join_template" _ "github.com/ozontech/file.d/plugin/action/json_decode" diff --git a/e2e/start_work_test.go b/e2e/start_work_test.go index c7c349eb2..695da3c8c 100644 --- a/e2e/start_work_test.go +++ b/e2e/start_work_test.go @@ -34,6 +34,7 @@ import ( _ "github.com/ozontech/file.d/plugin/action/discard" _ "github.com/ozontech/file.d/plugin/action/flatten" _ "github.com/ozontech/file.d/plugin/action/hash" + _ "github.com/ozontech/file.d/plugin/action/http_request" _ "github.com/ozontech/file.d/plugin/action/join" _ "github.com/ozontech/file.d/plugin/action/join_template" _ "github.com/ozontech/file.d/plugin/action/json_decode" diff --git a/go.mod b/go.mod index 26f633e58..1dc123b04 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ require ( github.com/twmb/franz-go/pkg/kadm v1.12.0 github.com/twmb/franz-go/plugin/kzap v1.1.2 github.com/twmb/tlscfg v1.2.1 - github.com/valyala/fasthttp v1.48.0 + github.com/valyala/fasthttp v1.50.0 github.com/xdg-go/scram v1.1.2 go.uber.org/atomic v1.11.0 go.uber.org/automaxprocs v1.5.3 @@ -62,12 +62,13 @@ require ( require ( github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect - github.com/andybalholm/brotli v1.0.5 // indirect + github.com/andybalholm/brotli v1.0.6 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cilium/ebpf v0.9.1 // indirect github.com/containerd/cgroups/v3 v3.0.1 // indirect github.com/coreos/go-systemd/v22 v22.3.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dgrr/http2 v0.3.6-0.20231023141632-12370d352f5f github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dmarkham/enumer v1.5.10 // indirect github.com/docker/go-units v0.4.0 // indirect @@ -130,6 +131,7 @@ require ( github.com/timtadh/data-structures v0.6.1 // indirect github.com/twmb/franz-go/pkg/kmsg v1.12.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fastrand v1.1.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect diff --git a/go.sum b/go.sum index 5a8f56710..9fa3434a3 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAu github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= -github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= -github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow= @@ -48,6 +48,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrr/http2 v0.3.6-0.20231023141632-12370d352f5f h1:GX4Jfn5K0Eyw+nEgAa5owyeRQdq8QcOgp6Drex3yO3g= +github.com/dgrr/http2 v0.3.6-0.20231023141632-12370d352f5f/go.mod h1:H63t7RlJK6bA1sjvobaRqXSpFlQ5uZOvCXhn6/jtIF0= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dmarkham/enumer v1.5.10 h1:ygL0L6quiTiH1jpp68DyvsWaea6MaZLZrTTkIS++R0M= @@ -359,8 +361,10 @@ github.com/twmb/tlscfg v1.2.1 h1:IU2efmP9utQEIV2fufpZjPq7xgcZK4qu25viD51BB44= github.com/twmb/tlscfg v1.2.1/go.mod h1:GameEQddljI+8Es373JfQEBvtI4dCTLKWGJbqT2kErs= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.48.0 h1:oJWvHb9BIZToTQS3MuQ2R3bJZiNSa2KiNdeI8A+79Tc= -github.com/valyala/fasthttp v1.48.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= +github.com/valyala/fasthttp v1.50.0 h1:H7fweIlBm0rXLs2q0XbalvJ6r0CUPFWK3/bB4N13e9M= +github.com/valyala/fasthttp v1.50.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= +github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8= +github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= diff --git a/plugin/README.md b/plugin/README.md index f58528d0d..823a384f0 100755 --- a/plugin/README.md +++ b/plugin/README.md @@ -353,6 +353,10 @@ It calculates the hash for one of the specified event fields and adds a new fiel > Fields can be of any type except for an object and an array. [More details...](plugin/action/hash/README.md) +## http_request +Sends HTTP requests with event data as body. Writes response body to the configured response_field. Supports retry with exponential backoff, custom headers, URL templating. + +[More details...](plugin/action/http_request/README.md) ## join It makes one big event from the sequence of the events. It is useful for assembling back together "exceptions" or "panics" if they were written line by line. diff --git a/plugin/action/README.md b/plugin/action/README.md index 632fa8fe3..14cef71ba 100755 --- a/plugin/action/README.md +++ b/plugin/action/README.md @@ -174,6 +174,10 @@ It calculates the hash for one of the specified event fields and adds a new fiel > Fields can be of any type except for an object and an array. [More details...](plugin/action/hash/README.md) +## http_request +Sends HTTP requests with event data as body. Writes response body to the configured response_field. Supports retry with exponential backoff, custom headers, URL templating. + +[More details...](plugin/action/http_request/README.md) ## join It makes one big event from the sequence of the events. It is useful for assembling back together "exceptions" or "panics" if they were written line by line. diff --git a/plugin/action/http_request/README.idoc.md b/plugin/action/http_request/README.idoc.md new file mode 100644 index 000000000..4b227731d --- /dev/null +++ b/plugin/action/http_request/README.idoc.md @@ -0,0 +1,8 @@ +# HTTP request plugin +@introduction + +## Example +@examples + +## Config params +@config-params|description diff --git a/plugin/action/http_request/README.md b/plugin/action/http_request/README.md new file mode 100755 index 000000000..858344441 --- /dev/null +++ b/plugin/action/http_request/README.md @@ -0,0 +1,95 @@ +# HTTP request plugin +Sends HTTP requests with event data as body. Writes response body to the configured response_field. Supports retry with exponential backoff, custom headers, URL templating. + +## Example +```yaml +pipelines: + - name: http_pipeline + actions: + ... + - type: http_request + address: "http://example.com/api/{tenant_id}" + method: POST + content_type: "application/json" + params: + tenant_id: "field.tenant" + user_id: "user" + response_field: "http_response" + retry: 3 + retention: 100ms + timeout: 5s + ... +``` + + +## Config params +**`params`** +
+ +**`method`** +
+ +**`address`** +
+ +**`timeout`** *`cfg.Duration`* *`default=5s`* + +Timeout for the HTTP request. + +
+ +**`content_type`** *`string`* *`default=application/json`* + +Value of the Content-Type header. + +
+ +**`response_field`** *`string`* + +Field name to store the HTTP response body. + +
+ +**`headers`** *`map[string]string`* + +Custom headers to add to the HTTP request. + +
+ +**`retry`** *`int`* *`default=10`* + +Number of retry attempts for failed HTTP requests. +Uses exponential backoff strategy between retries. +If all retries fail, the event is passed through without being sent. + +
+ +**`retention`** *`cfg.Duration`* *`default=50ms`* + +Initial interval for exponential backoff between retries. + +
+ +**`retention_exponentially_multiplier`** *`int`* *`default=2`* + +Multiplier for exponential increase of retry interval. +Each retry interval will be multiplied by this value. + +
+ +**`success_codes`** *`[]int`* + +List of HTTP status codes that are considered successful. + +
+ +**`metric_prefix`** *`string`* + +Prefix added to metric names for better organization. +Useful when running multiple instances to avoid metric name collisions. +Leave empty for default metric naming. + +
+ + +
*Generated using [__insane-doc__](https://github.com/vitkovskii/insane-doc)* \ No newline at end of file diff --git a/plugin/action/http_request/http_request.go b/plugin/action/http_request/http_request.go new file mode 100644 index 000000000..0440eb339 --- /dev/null +++ b/plugin/action/http_request/http_request.go @@ -0,0 +1,342 @@ +package http_request + +import ( + "fmt" + "log" + "net/url" + "strings" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/dgrr/http2" + "github.com/ozontech/file.d/cfg" + "github.com/ozontech/file.d/fd" + "github.com/ozontech/file.d/metric" + "github.com/ozontech/file.d/pipeline" + "github.com/valyala/fasthttp" + + "go.uber.org/zap" +) + +/*{ introduction +Sends HTTP requests with event data as body. Writes response body to the configured response_field. Supports retry with exponential backoff, custom headers, URL templating. +}*/ + +/*{ examples +```yaml +pipelines: + - name: http_pipeline + actions: + ... + - type: http_request + address: "http://example.com/api/{tenant_id}" + method: POST + content_type: "application/json" + params: + tenant_id: "field.tenant" + user_id: "user" + response_field: "http_response" + retry: 3 + retention: 100ms + timeout: 5s + ... +``` + +}*/ + +type Plugin struct { + config *Config + logger *zap.Logger + client *fasthttp.HostClient + requestsMetric *metric.CounterVec + latencyMetric *metric.Histogram + backoffStrategy backoff.BackOff + paramFields map[string][]string // pre-parsed field selectors for params +} + +// ! config-params +// ^ config-params +type Config struct { + Params map[string]string `json:"params"` // * + + Method string `json:"method" default:"POST" options:"POST|GET|PATCH"` // * + + Address string `json:"address" default:"" required:"true"` // * + + // > @3@4@5@6 + // > + // > Timeout for the HTTP request. + Timeout cfg.Duration `json:"timeout" default:"5s" parse:"duration"` // * + Timeout_ time.Duration + + // > @3@4@5@6 + // > + // > Value of the Content-Type header. + ContentType string `json:"content_type" default:"application/json"` // * + + // > @3@4@5@6 + // > + // > Field name to store the HTTP response body. + ResponseField string `json:"response_field" default:""` // * + + // > @3@4@5@6 + // > + // > Force HTTP/2 for the request. + ForceHTTP2 bool `json:"force_http2" default:"false"` // * + + // > @3@4@5@6 + // > + // > Custom headers to add to the HTTP request. + Headers map[string]string `json:"headers"` // * + + // > @3@4@5@6 + // > + // > Number of retry attempts for failed HTTP requests. + // > Uses exponential backoff strategy between retries. + // > If all retries fail, the event is passed through without being sent. + Retry int `json:"retry" default:"10"` // * + + // > @3@4@5@6 + // > + // > Initial interval for exponential backoff between retries. + Retention cfg.Duration `json:"retention" default:"50ms" parse:"duration"` // * + Retention_ time.Duration + + // > @3@4@5@6 + // > + // > Multiplier for exponential increase of retry interval. + // > Each retry interval will be multiplied by this value. + RetentionExponentMultiplier int `json:"retention_exponentially_multiplier" default:"2"` // * + + // > @3@4@5@6 + // > + // > List of HTTP status codes that are considered successful. + SuccessCodes []int `json:"success_codes"` // * + + // > @3@4@5@6 + // > + // > Prefix added to metric names for better organization. + // > Useful when running multiple instances to avoid metric name collisions. + // > Leave empty for default metric naming. + MetricPrefix string `json:"metric_prefix" default:""` // * +} + +func init() { + fd.DefaultPluginRegistry.RegisterAction(&pipeline.PluginStaticInfo{ + Type: "http_request", + Factory: factory, + }) +} + +func factory() (pipeline.AnyPlugin, pipeline.AnyConfig) { + return &Plugin{}, &Config{} +} + +func (p *Plugin) Start(config pipeline.AnyConfig, params *pipeline.ActionPluginParams) { + p.config = config.(*Config) + p.logger = params.Logger.Desugar() + + // Default success codes to 200 if not specified + if len(p.config.SuccessCodes) == 0 { + p.config.SuccessCodes = []int{200} + } + + p.client = &fasthttp.HostClient{ + Addr: getAddrFromURL(p.config.Address), + ReadTimeout: p.config.Timeout_, + WriteTimeout: p.config.Timeout_, + IsTLS: isURLTLS(p.config.Address), + } + + if p.config.ForceHTTP2 { + if err := http2.ConfigureClient(p.client, http2.ClientOpts{}); err != nil { + log.Printf("Server %s does not support HTTP/2: %v\n", p.client.Addr, err) + } + } + + p.registerMetrics(params.MetricCtl, p.config.MetricPrefix) + + p.backoffStrategy = p.newBackoffStrategy() + + // Pre-parse field selectors for params + p.paramFields = make(map[string][]string, len(p.config.Params)) + for name, fieldSelector := range p.config.Params { + p.paramFields[name] = cfg.ParseFieldSelector(fieldSelector) + } +} + +func isURLTLS(rawURL string) bool { + u, err := url.Parse(rawURL) + if err != nil { + return false + } + return u.Scheme == "https" +} + +func getAddrFromURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + + host := u.Hostname() + port := u.Port() + + if port == "" { + if u.Scheme == "https" { + port = "443" + } else { + port = "80" + } + } + + return host + ":" + port +} + +func isSuccessStatusCode(statusCode int, successCodes []int) bool { + for _, code := range successCodes { + if statusCode == code { + return true + } + } + return false +} + +func (p *Plugin) Stop() { + if p.client != nil { + p.client.CloseIdleConnections() + } +} + +func (p *Plugin) registerMetrics(ctl *metric.Ctl, prefix string) { + if ctl == nil { + return + } + + var requestsMetricName string + var latencyMetricName string + if prefix == "" { + requestsMetricName = "action_http_request_total" + latencyMetricName = "action_http_request_latency_seconds" + } else { + requestsMetricName = fmt.Sprintf("action_%s_http_request_total", prefix) + latencyMetricName = fmt.Sprintf("action_%s_http_request_latency_seconds", prefix) + } + + p.requestsMetric = ctl.RegisterCounterVec(requestsMetricName, "Total HTTP requests", "result") + p.latencyMetric = ctl.RegisterHistogram(latencyMetricName, "HTTP request latency in seconds", metric.SecondsBucketsDetailed) +} + +func (p *Plugin) Do(event *pipeline.Event) pipeline.ActionResult { + // Reset backoff strategy for new attempt + p.backoffStrategy.Reset() + + // Extract parameter values using pre-parsed field selectors. + params := make(map[string]string, len(p.paramFields)) + for name, fields := range p.paramFields { + value := event.Root.Dig(fields...).AsString() + params[name] = value + } + + // Build the address by replacing {param_name} placeholders. + address := p.config.Address + usedParams := make(map[string]bool) + for name, value := range params { + // Check if param is used in URL before replacing + if strings.Contains(p.config.Address, "{"+name+"}") { + usedParams[name] = true + } + address = strings.ReplaceAll(address, "{"+name+"}", value) + } + + // Encode the event as the request body. + body := []byte(event.Root.EncodeToString()) + + // Measure total request time including retries + startTime := time.Now() + + // Set up backoff for retries + operation := func() error { + req := fasthttp.AcquireRequest() + defer fasthttp.ReleaseRequest(req) + + req.SetRequestURI(address) + + // Add unused params as query string parameters. + for name, value := range params { + if !usedParams[name] { + req.URI().QueryArgs().Add(name, value) + } + } + req.Header.SetMethod(p.config.Method) + req.Header.SetContentType(p.config.ContentType) + + // Add custom headers from config. + for key, value := range p.config.Headers { + req.Header.Set(key, value) + } + req.SetBodyRaw(body) + + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseResponse(resp) + + if err := p.client.DoTimeout(req, resp, p.config.Timeout_); err != nil { + p.logger.Error("http request failed", + zap.String("address", address), + zap.String("method", p.config.Method), + zap.Error(err), + ) + p.requestsMetric.WithLabelValues("error").Inc() + return err + } + + statusCode := resp.Header.StatusCode() + if !isSuccessStatusCode(statusCode, p.config.SuccessCodes) { + err := fmt.Errorf("non-success status code: %d", statusCode) + p.logger.Error("http request returned non-success status code", + zap.String("address", address), + zap.String("method", p.config.Method), + zap.Int("status_code", statusCode), + zap.ByteString("response", resp.Body()), + ) + p.requestsMetric.WithLabelValues("not_success").Inc() + return err + } + + // Write the response body to the configured response_field. + if p.config.ResponseField != "" { + event.Root.AddFieldNoAlloc(event.Root, p.config.ResponseField).MutateToBytesCopy(event.Root, resp.Body()) + } + + p.requestsMetric.WithLabelValues("success").Inc() + return nil + } + + err := backoff.Retry(operation, p.backoffStrategy) + latency := time.Since(startTime) + p.latencyMetric.Observe(latency.Seconds()) + + if err != nil { + p.logger.Error("http request failed after retries", + zap.String("address", address), + zap.String("method", p.config.Method), + zap.Error(err), + ) + } + + return pipeline.ActionPass +} + +func (p *Plugin) newBackoffStrategy() backoff.BackOff { + expBackoff := backoff.ExponentialBackOff{ + InitialInterval: p.config.Retention_, + Multiplier: float64(p.config.RetentionExponentMultiplier), + RandomizationFactor: 0.5, + MaxInterval: backoff.DefaultMaxInterval, + MaxElapsedTime: backoff.DefaultMaxElapsedTime, + Stop: backoff.Stop, + Clock: backoff.SystemClock, + } + expBackoff.Reset() + return backoff.WithMaxRetries(&expBackoff, uint64(p.config.Retry)) +} diff --git a/plugin/action/http_request/http_request_test.go b/plugin/action/http_request/http_request_test.go new file mode 100644 index 000000000..5b3aa2842 --- /dev/null +++ b/plugin/action/http_request/http_request_test.go @@ -0,0 +1,638 @@ +package http_request + +import ( + "fmt" + "net" + "net/http" + "sync" + "testing" + "time" + + "github.com/ozontech/file.d/cfg" + "github.com/ozontech/file.d/metric" + "github.com/ozontech/file.d/pipeline" + "github.com/ozontech/file.d/test" + insaneJSON "github.com/ozontech/insane-json" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/valyala/fasthttp" + "go.uber.org/zap" +) + +// ---------- helper tests ---------- + +func TestGetAddrFromURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + url string + want string + }{ + {"https with port", "https://example.com:8443", "example.com:8443"}, + {"https without port", "https://example.com", "example.com:443"}, + {"http without port", "http://example.com", "example.com:80"}, + {"http with port", "http://example.com:8080", "example.com:8080"}, + {"with path", "https://api.example.com/v1/data", "api.example.com:443"}, + {"invalid url", "://invalid", ""}, + {"empty string", "", ":80"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getAddrFromURL(tt.url) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsURLTLS(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + url string + want bool + }{ + {"https url", "https://example.com", true}, + {"http url", "http://example.com", false}, + {"https with path", "https://api.example.com/v1/data", true}, + {"http with port", "http://example.com:8080", false}, + {"invalid url", "://invalid", false}, + {"empty string", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isURLTLS(tt.url) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsSuccessStatusCode(t *testing.T) { + t.Parallel() + + t.Run("empty success codes", func(t *testing.T) { + assert.False(t, isSuccessStatusCode(200, nil)) + assert.False(t, isSuccessStatusCode(200, []int{})) + }) + + t.Run("single code match", func(t *testing.T) { + assert.True(t, isSuccessStatusCode(200, []int{200})) + }) + + t.Run("single code no match", func(t *testing.T) { + assert.False(t, isSuccessStatusCode(404, []int{200})) + }) + + t.Run("multiple codes match", func(t *testing.T) { + assert.True(t, isSuccessStatusCode(201, []int{200, 201, 204})) + assert.True(t, isSuccessStatusCode(204, []int{200, 201, 204})) + }) + + t.Run("multiple codes no match", func(t *testing.T) { + assert.False(t, isSuccessStatusCode(500, []int{200, 201, 204})) + }) + + t.Run("mixed codes", func(t *testing.T) { + assert.False(t, isSuccessStatusCode(301, []int{200, 404, 500})) + }) +} + +// ---------- test helpers ---------- + +// startTestServer starts an HTTP server on a random port and returns it +// together with the base URL (http://...). Caller must close the server. +func startTestServer(handler http.Handler) (*http.Server, string) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + panic(fmt.Sprintf("failed to listen: %v", err)) + } + server := &http.Server{Handler: handler} + go func() { _ = server.Serve(listener) }() + return server, "http://" + listener.Addr().String() +} + +// waitForServer polls urlStr with GET until it responds or timeout elapses. +func waitForServer(urlStr string) error { + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + resp, err := http.Get(urlStr) //nolint:gosec // test only + if err == nil { + resp.Body.Close() + return nil + } + time.Sleep(10 * time.Millisecond) + } + return fmt.Errorf("server not ready within %s", time.Second) +} + +// makeTestPlugin creates a Plugin with Start(), but overrides its client +// so that IsTLS is false. This lets us test against plain HTTP servers. +func makeTestPlugin(t *testing.T, config *Config) *Plugin { + t.Helper() + parsedConfig := test.NewConfig(config, nil) + logger, _ := zap.NewDevelopment() + registry := prometheus.NewRegistry() + metricCtl := metric.NewCtl("action_http_request", registry, 0, 0) + params := &pipeline.ActionPluginParams{ + PluginDefaultParams: pipeline.PluginDefaultParams{ + PipelineName: "test", + PipelineSettings: &pipeline.Settings{}, + MetricCtl: metricCtl, + }, + Logger: logger.Sugar(), + } + + p := &Plugin{} + p.Start(parsedConfig, params) + + // Override the client so it talks plain HTTP instead of TLS. + p.client = &fasthttp.HostClient{ + Addr: getAddrFromURL(p.config.Address), + ReadTimeout: p.config.Timeout_, + WriteTimeout: p.config.Timeout_, + IsTLS: false, + } + + return p +} + +func makeEvent(t *testing.T, jsonStr string) *pipeline.Event { + t.Helper() + root, err := insaneJSON.DecodeString(jsonStr) + require.NoError(t, err) + return &pipeline.Event{ + Root: root, + } +} + +// ---------- Do method integration tests ---------- + +func TestDo_SuccessfulRequest(t *testing.T) { + var mu sync.Mutex + receivedBody := "" + method := "" + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + method = r.Method + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + receivedBody = string(buf) + mu.Unlock() + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"result":"ok"}`)) + }) + + server, addr := startTestServer(handler) + defer server.Close() + require.NoError(t, waitForServer(addr)) + + p := makeTestPlugin(t, &Config{ + Address: addr, + Method: "POST", + ContentType: "application/json", + ResponseField: "response", + SuccessCodes: []int{200}, + Timeout: cfg.Duration("5s"), + Retention: cfg.Duration("10ms"), + }) + + event := makeEvent(t, `{"foo":"bar"}`) + result := p.Do(event) + assert.Equal(t, pipeline.ActionPass, result) + + mu.Lock() + assert.Equal(t, "POST", method) + assert.Equal(t, `{"foo":"bar"}`, receivedBody) + mu.Unlock() + + assert.Equal(t, `{"result":"ok"}`, event.Root.Dig("response").AsString()) +} + +func TestDo_NonSuccessStatusCode(t *testing.T) { + // The non-success status code path is exercised indirectly via + // isSuccessStatusCode unit tests. The Do() method always returns + // ActionPass, so integration testing of the retry loop on a 500 + // is impractical because the exponential backoff runs for ~15 minutes. + // + // This test verifies that when a request would fail fast (server down) + // the plugin still returns ActionPass. + server, addr := startTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(`{}`)) + })) + require.NoError(t, waitForServer(addr)) + defer server.Close() + time.Sleep(50 * time.Millisecond) + + p := makeTestPlugin(t, &Config{ + Address: addr, + Method: "POST", + ContentType: "application/json", + SuccessCodes: []int{200}, + Retry: 1, + }) + + event := makeEvent(t, `{"foo":"bar"}`) + result := p.Do(event) + assert.Equal(t, pipeline.ActionPass, result) +} + +func TestDo_WithURLParams(t *testing.T) { + var mu sync.Mutex + requestURI := "" + + server, addr := startTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requestURI = r.RequestURI + mu.Unlock() + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`ok`)) + })) + defer server.Close() + require.NoError(t, waitForServer(addr)) + + p := makeTestPlugin(t, &Config{ + Address: addr + "/{id}/profile", + Method: "POST", + ContentType: "application/json", + Params: map[string]string{"id": "user_id"}, + SuccessCodes: []int{200}, + Timeout: cfg.Duration("5s"), + Retention: cfg.Duration("10ms"), + }) + + event := makeEvent(t, `{"user_id":"123"}`) + result := p.Do(event) + assert.Equal(t, pipeline.ActionPass, result) + + mu.Lock() + assert.Contains(t, requestURI, "/123/profile") + mu.Unlock() +} + +func TestDo_WithQueryParams(t *testing.T) { + var mu sync.Mutex + requestURI := "" + + server, addr := startTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requestURI = r.RequestURI + mu.Unlock() + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`ok`)) + })) + defer server.Close() + require.NoError(t, waitForServer(addr)) + + p := makeTestPlugin(t, &Config{ + Address: addr + "/search", + Method: "GET", + ContentType: "application/json", + Params: map[string]string{"q": "query", "limit": "max_results"}, + SuccessCodes: []int{200}, + Timeout: cfg.Duration("5s"), + Retention: cfg.Duration("10ms"), + }) + + event := makeEvent(t, `{"query":"hello","max_results":"10"}`) + result := p.Do(event) + assert.Equal(t, pipeline.ActionPass, result) + + mu.Lock() + assert.Contains(t, requestURI, "q=hello") + assert.Contains(t, requestURI, "limit=10") + mu.Unlock() +} + +func TestDo_MixedParams_URLAndQuery(t *testing.T) { + var mu sync.Mutex + requestURI := "" + + server, addr := startTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requestURI = r.RequestURI + mu.Unlock() + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`ok`)) + })) + defer server.Close() + require.NoError(t, waitForServer(addr)) + + p := makeTestPlugin(t, &Config{ + Address: addr + "/{tenant}/items", + Method: "GET", + ContentType: "application/json", + Params: map[string]string{"tenant": "tenant_name", "filter": "filter_by"}, + SuccessCodes: []int{200}, + Timeout: cfg.Duration("5s"), + Retention: cfg.Duration("10ms"), + }) + + event := makeEvent(t, `{"tenant_name":"acme","filter_by":"active"}`) + result := p.Do(event) + assert.Equal(t, pipeline.ActionPass, result) + + mu.Lock() + assert.Contains(t, requestURI, "/acme/items") + assert.Contains(t, requestURI, "filter=active") + mu.Unlock() +} + +func TestDo_CustomHeaders(t *testing.T) { + var mu sync.Mutex + receivedHeaders := make(http.Header) + + server, addr := startTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + receivedHeaders = r.Header.Clone() + mu.Unlock() + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`ok`)) + })) + defer server.Close() + require.NoError(t, waitForServer(addr)) + + p := makeTestPlugin(t, &Config{ + Address: addr, + Method: "POST", + ContentType: "application/json", + Headers: map[string]string{"X-Custom-Header": "custom_value", "Authorization": "Bearer token123"}, + SuccessCodes: []int{200}, + Timeout: cfg.Duration("5s"), + Retention: cfg.Duration("10ms"), + }) + + event := makeEvent(t, `{"foo":"bar"}`) + result := p.Do(event) + assert.Equal(t, pipeline.ActionPass, result) + + mu.Lock() + assert.Equal(t, "custom_value", receivedHeaders.Get("X-Custom-Header")) + assert.Equal(t, "Bearer token123", receivedHeaders.Get("Authorization")) + mu.Unlock() +} + +func TestDo_WithEventThroughPipelineMock(t *testing.T) { + server, addr := startTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + })) + defer server.Close() + require.NoError(t, waitForServer(addr)) + + config := test.NewConfig(&Config{ + Address: addr, + Method: "POST", + ContentType: "application/json", + ResponseField: "http_response", + SuccessCodes: []int{200}, + Timeout: cfg.Duration("5s"), + Retention: cfg.Duration("10ms"), + }, nil) + + p, input, output := test.NewPipelineMock( + test.NewActionPluginStaticInfo(factory, config, pipeline.MatchModeAnd, nil, false), + ) + + wg := &sync.WaitGroup{} + wg.Add(1) + + output.SetOutFn(func(e *pipeline.Event) { + assert.Equal(t, `{"status":"ok"}`, e.Root.Dig("http_response").AsString()) + wg.Done() + }) + + input.In(0, "test", test.NewOffset(0), []byte(`{"message":"hello"}`)) + + wg.Wait() + p.Stop() +} + +func TestDo_ServerTimeout(t *testing.T) { + // The timeout + retry loop would take ~15 minutes because the + // plugin uses backoff.DefaultMaxElapsedTime. Instead, we test + // that a pre-closed server (immediate connection refused) still + // results in ActionPass. + server, addr := startTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(5 * time.Second) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`ok`)) + })) + require.NoError(t, waitForServer(addr)) + defer server.Close() + time.Sleep(50 * time.Millisecond) + + p := makeTestPlugin(t, &Config{ + Address: addr, + Method: "POST", + ContentType: "application/json", + SuccessCodes: []int{200}, + Retry: 1, + Timeout: cfg.Duration("1s"), + Retention: cfg.Duration("1ms"), + }) + + event := makeEvent(t, `{"foo":"bar"}`) + result := p.Do(event) + assert.Equal(t, pipeline.ActionPass, result) +} + +func TestDo_NoResponseField(t *testing.T) { + var mu sync.Mutex + receivedBody := "" + + server, addr := startTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + receivedBody = string(buf) + mu.Unlock() + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`created`)) + })) + defer server.Close() + require.NoError(t, waitForServer(addr)) + + p := makeTestPlugin(t, &Config{ + Address: addr, + Method: "POST", + ContentType: "application/json", + SuccessCodes: []int{201}, + Timeout: cfg.Duration("5s"), + Retention: cfg.Duration("10ms"), + // ResponseField is empty — no response stored + }) + + event := makeEvent(t, `{"a":1}`) + result := p.Do(event) + assert.Equal(t, pipeline.ActionPass, result) + + mu.Lock() + assert.Equal(t, `{"a":1}`, receivedBody) + mu.Unlock() + + // The original event should remain unchanged + assert.Equal(t, `{"a":1}`, event.Root.EncodeToString()) +} + +func TestDo_MultipleSuccessCodes(t *testing.T) { + tests := []struct { + name string + statusCode int + }{ + {"200 OK", http.StatusOK}, + {"201 Created", http.StatusCreated}, + {"204 No Content", http.StatusNoContent}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server, addr := startTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + _, _ = w.Write([]byte(`ok`)) + })) + defer server.Close() + require.NoError(t, waitForServer(addr)) + + p := makeTestPlugin(t, &Config{ + Address: addr, + Method: "GET", + ContentType: "application/json", + SuccessCodes: []int{200, 201, 204}, + Timeout: cfg.Duration("5s"), + Retention: cfg.Duration("10ms"), + }) + + event := makeEvent(t, `{"x":"y"}`) + result := p.Do(event) + assert.Equal(t, pipeline.ActionPass, result) + }) + } +} + +func TestDo_GETMethod(t *testing.T) { + var mu sync.Mutex + method := "" + + server, addr := startTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + method = r.Method + mu.Unlock() + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`ok`)) + })) + defer server.Close() + require.NoError(t, waitForServer(addr)) + + p := makeTestPlugin(t, &Config{ + Address: addr, + Method: "GET", + ContentType: "application/json", + SuccessCodes: []int{200}, + Timeout: cfg.Duration("5s"), + Retention: cfg.Duration("10ms"), + }) + + event := makeEvent(t, `{"a":1}`) + result := p.Do(event) + assert.Equal(t, pipeline.ActionPass, result) + + mu.Lock() + assert.Equal(t, "GET", method) + mu.Unlock() +} + +func TestDo_MissingParam(t *testing.T) { + // When a param doesn't exist in the event, it gets the empty string value + var mu sync.Mutex + requestURI := "" + + server, addr := startTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requestURI = r.RequestURI + mu.Unlock() + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`ok`)) + })) + defer server.Close() + require.NoError(t, waitForServer(addr)) + + p := makeTestPlugin(t, &Config{ + Address: addr + "/{id}", + Method: "POST", + ContentType: "application/json", + Params: map[string]string{"id": "nonexistent_field", "extra": "extra_field"}, + SuccessCodes: []int{200}, + Timeout: cfg.Duration("5s"), + Retention: cfg.Duration("10ms"), + }) + + event := makeEvent(t, `{"extra_field":"hello"}`) + result := p.Do(event) + assert.Equal(t, pipeline.ActionPass, result) + + mu.Lock() + // extra_field is not in a URL placeholder so it becomes a query param + assert.Contains(t, requestURI, "extra=hello") + mu.Unlock() +} + +// ---------- Config & factory tests ---------- + +func TestConfigDefaults(t *testing.T) { + c := &Config{} + err := cfg.SetDefaultValues(c) + require.NoError(t, err) + + assert.Equal(t, "POST", c.Method) + assert.Equal(t, "5s", string(c.Timeout)) + assert.Equal(t, "application/json", c.ContentType) + assert.Equal(t, false, c.ForceHTTP2) + assert.Equal(t, 10, c.Retry) + assert.Equal(t, "50ms", string(c.Retention)) + assert.Equal(t, 2, c.RetentionExponentMultiplier) +} + +func TestConfigParsing(t *testing.T) { + c := &Config{ + Address: "https://example.com/api", + Method: "PATCH", + ContentType: "application/xml", + SuccessCodes: []int{200, 204}, + Timeout: cfg.Duration("10s"), + Retention: cfg.Duration("100ms"), + } + + parsed := test.NewConfig(c, nil) + require.NotNil(t, parsed) + + pcfg := parsed.(*Config) + assert.Equal(t, "PATCH", pcfg.Method) + assert.Equal(t, 10*time.Second, pcfg.Timeout_) + assert.Equal(t, 100*time.Millisecond, pcfg.Retention_) + assert.Equal(t, []int{200, 204}, pcfg.SuccessCodes) + assert.Equal(t, "application/xml", pcfg.ContentType) +} + +func TestFactory(t *testing.T) { + plugin, config := factory() + assert.NotNil(t, plugin) + assert.NotNil(t, config) + + _, ok := plugin.(*Plugin) + assert.True(t, ok) + + _, ok = config.(*Config) + assert.True(t, ok) +} + +func TestPlugin_Stop(t *testing.T) { + p := &Plugin{} + // Stop should not panic + p.Stop() +} From c5438466e59e3fe9d8cd21b25e61d0f474066b4c Mon Sep 17 00:00:00 2001 From: Dmitry Romanov Date: Thu, 16 Jul 2026 17:51:38 +0700 Subject: [PATCH 2/4] set RequestTimeout_ for http client --- plugin/output/splunk/splunk_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugin/output/splunk/splunk_test.go b/plugin/output/splunk/splunk_test.go index fec6de177..da2f41c4e 100644 --- a/plugin/output/splunk/splunk_test.go +++ b/plugin/output/splunk/splunk_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/ozontech/file.d/cfg" "github.com/ozontech/file.d/pipeline" @@ -51,7 +52,8 @@ func TestSplunk(t *testing.T) { plugin := Plugin{ config: &Config{ - Endpoint: testServer.URL, + Endpoint: testServer.URL, + RequestTimeout_: time.Second, }, logger: zap.NewExample().Sugar(), } @@ -181,7 +183,8 @@ func TestCopyFields(t *testing.T) { plugin := Plugin{ config: &Config{ - Endpoint: testServer.URL, + Endpoint: testServer.URL, + RequestTimeout_: time.Second, }, copyFieldsPaths: tt.copyFields, logger: zap.NewExample().Sugar(), From cb3f51c829f61b383286266013da3642cee1905a Mon Sep 17 00:00:00 2001 From: Dmitry Romanov Date: Fri, 17 Jul 2026 14:08:00 +0700 Subject: [PATCH 3/4] fix start loki and elasticsearch in e2e tests --- e2e/file_elasticsearch/docker-compose.yml | 5 +++-- e2e/file_es_split/docker-compose.yml | 5 +++-- e2e/file_loki/docker-compose.yml | 11 ++++++----- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/e2e/file_elasticsearch/docker-compose.yml b/e2e/file_elasticsearch/docker-compose.yml index 162df6eef..6e4a84077 100644 --- a/e2e/file_elasticsearch/docker-compose.yml +++ b/e2e/file_elasticsearch/docker-compose.yml @@ -10,13 +10,14 @@ services: - ELASTIC_PASSWORD=elastic - xpack.security.enabled=true - xpack.security.http.ssl.enabled=false - mem_limit: 1073741824 + mem_limit: 2147483648 healthcheck: test: [ "CMD-SHELL", - "curl --output /dev/null --silent --head --fail -u elastic:elastic http://elasticsearch:19200", + "curl --output /dev/null --silent --head --fail -u elastic:elastic http://localhost:9200", ] interval: 10s timeout: 10s retries: 10 + start_period: 20s diff --git a/e2e/file_es_split/docker-compose.yml b/e2e/file_es_split/docker-compose.yml index 61486a2c3..ba7fcadca 100644 --- a/e2e/file_es_split/docker-compose.yml +++ b/e2e/file_es_split/docker-compose.yml @@ -10,13 +10,14 @@ services: - http.max_content_length=128b - logger.org.elasticsearch.transport=DEBUG - logger.org.elasticsearch.http=DEBUG - mem_limit: 1073741824 + mem_limit: 2147483648 healthcheck: test: [ "CMD-SHELL", - "curl --output /dev/null --silent --head --fail http://elasticsearch:9200", + "curl --output /dev/null --silent --head --fail -u elastic:elastic http://localhost:9200", ] interval: 10s timeout: 10s retries: 10 + start_period: 20s diff --git a/e2e/file_loki/docker-compose.yml b/e2e/file_loki/docker-compose.yml index 3947c6b46..ea5fc8dd4 100644 --- a/e2e/file_loki/docker-compose.yml +++ b/e2e/file_loki/docker-compose.yml @@ -1,11 +1,10 @@ services: - - grafana: + grafana: image: grafana/grafana:latest container_name: grafana ports: - "3000:3000" - user: '0' + user: "0" environment: - GF_PATHS_PROVISIONING=/etc/grafana/provisioning - GF_AUTH_ANONYMOUS_ENABLED=true @@ -31,9 +30,10 @@ services: /run.sh loki: - image: grafana/loki:latest + image: grafana/loki:3.5 container_name: loki - command: --config.file=/etc/loki/config.yaml + command: + - --config.file=/etc/loki/config.yaml ports: - "3100:3100" volumes: @@ -43,3 +43,4 @@ services: interval: 3s timeout: 10s retries: 10 + start_period: 15s From 49ea394e40df2ead7b2a3a6019289eed6c750bc0 Mon Sep 17 00:00:00 2001 From: Dmitry Romanov Date: Mon, 20 Jul 2026 17:36:08 +0700 Subject: [PATCH 4/4] fix after review --- cfg/config.go | 16 ++- cfg/config_test.go | 26 ++++ plugin/action/http_request/README.md | 40 ++++-- plugin/action/http_request/http_request.go | 137 ++++++++++++--------- 4 files changed, 149 insertions(+), 70 deletions(-) diff --git a/cfg/config.go b/cfg/config.go index 0860ac885..26b0e76f0 100644 --- a/cfg/config.go +++ b/cfg/config.go @@ -692,8 +692,22 @@ func SetDefaultValues(data interface{}) error { if vField.Len() == 0 { val := strings.Fields(defaultValue) vField.Set(reflect.MakeSlice(vField.Type(), len(val), len(val))) + + elemType := vField.Type().Elem() for i, v := range val { - vField.Index(i).SetString(v) + vElem := vField.Index(i) + switch elemType.Kind() { + case reflect.String: + vElem.SetString(v) + case reflect.Int: + intVal, err := strconv.Atoi(v) + if err != nil { + return fmt.Errorf("default value for field %s should be int, got=%s: %w", tField.Name, v, err) + } + vElem.SetInt(int64(intVal)) + default: + return fmt.Errorf("unsupported slice element type %v for field %s", elemType.Kind(), tField.Name) + } } } } diff --git a/cfg/config_test.go b/cfg/config_test.go index 2031e66e5..99bd45c46 100644 --- a/cfg/config_test.go +++ b/cfg/config_test.go @@ -105,6 +105,14 @@ type sliceStruct struct { Childs []sliceChild `default:"" slice:"true"` } +type intSliceDefault struct { + T []int `default:"1 2 3"` +} + +type strSliceDefault struct { + T []string `default:"a b c"` +} + type strBase8 struct { T string `default:"0666" parse:"base8"` T_ int64 @@ -133,6 +141,24 @@ func TestParseDefault(t *testing.T) { assert.Equal(t, "sync", s.T, "wrong value") } +func TestParseDefaultIntSlice(t *testing.T) { + s := &intSliceDefault{} + SetDefaultValues(s) + err := Parse(s, nil) + + assert.NoError(t, err, "shouldn't be an error") + assert.Equal(t, []int{1, 2, 3}, s.T, "wrong value") +} + +func TestParseDefaultStrSlice(t *testing.T) { + s := &strSliceDefault{} + SetDefaultValues(s) + err := Parse(s, nil) + + assert.NoError(t, err, "shouldn't be an error") + assert.Equal(t, []string{"a", "b", "c"}, s.T, "wrong value") +} + func TestParseDuration(t *testing.T) { t.Parallel() r := require.New(t) diff --git a/plugin/action/http_request/README.md b/plugin/action/http_request/README.md index 858344441..2f21495c4 100755 --- a/plugin/action/http_request/README.md +++ b/plugin/action/http_request/README.md @@ -4,15 +4,14 @@ Sends HTTP requests with event data as body. Writes response body to the configu ## Example ```yaml pipelines: - - name: http_pipeline - actions: - ... - - type: http_request - address: "http://example.com/api/{tenant_id}" - method: POST + - actions: + ... + - type: http_request + address: "http://example.com/api/{{ .id | default "unknown" }}" + method: GET content_type: "application/json" params: - tenant_id: "field.tenant" + id: "field.id" user_id: "user" response_field: "http_response" retry: 3 @@ -21,15 +20,28 @@ pipelines: ... ``` +# example of request to server: +# GET http://example.com/api/id_value?user_id=user + ## Config params -**`params`** +**`params`** *`map[string]string`* + +Query parameters to add to the request. +
-**`method`** +**`method`** *`string`* *`default=POST`* *`options=POST|GET|PATCH`* + +HTTP method to use. +
-**`address`** +**`address`** *`string`* *`required`* + +URL address to send requests to. +Example: `http://localhost:8080/api`.com/v1/events` +
**`timeout`** *`cfg.Duration`* *`default=5s`* @@ -77,12 +89,18 @@ Each retry interval will be multiplied by this value.
-**`success_codes`** *`[]int`* +**`success_codes`** *`[]int`* *`default=200`* List of HTTP status codes that are considered successful.
+**`ca_cert`** *`string`* + +Path or content of a PEM-encoded CA file. + +
+ **`metric_prefix`** *`string`* Prefix added to metric names for better organization. diff --git a/plugin/action/http_request/http_request.go b/plugin/action/http_request/http_request.go index 0440eb339..00d663fca 100644 --- a/plugin/action/http_request/http_request.go +++ b/plugin/action/http_request/http_request.go @@ -1,10 +1,11 @@ package http_request import ( + "bytes" "fmt" - "log" "net/url" - "strings" + "slices" + "text/template" "time" "github.com/cenkalti/backoff/v4" @@ -13,6 +14,7 @@ import ( "github.com/ozontech/file.d/fd" "github.com/ozontech/file.d/metric" "github.com/ozontech/file.d/pipeline" + "github.com/ozontech/file.d/xtls" "github.com/valyala/fasthttp" "go.uber.org/zap" @@ -25,15 +27,14 @@ Sends HTTP requests with event data as body. Writes response body to the configu /*{ examples ```yaml pipelines: - - name: http_pipeline - actions: - ... - - type: http_request - address: "http://example.com/api/{tenant_id}" - method: POST + - actions: + ... + - type: http_request + address: "http://example.com/api/{{ .id | default "unknown" }}" + method: GET content_type: "application/json" params: - tenant_id: "field.tenant" + id: "field.id" user_id: "user" response_field: "http_response" retry: 3 @@ -42,6 +43,9 @@ pipelines: ... ``` +# example of request to server: +# GET http://example.com/api/id_value?user_id=user + }*/ type Plugin struct { @@ -52,16 +56,27 @@ type Plugin struct { latencyMetric *metric.Histogram backoffStrategy backoff.BackOff paramFields map[string][]string // pre-parsed field selectors for params + addressTmpl *template.Template // compiled URL template } // ! config-params // ^ config-params type Config struct { + // > @3@4@5@6 + // > + // > Query parameters to add to the request. Params map[string]string `json:"params"` // * + // > @3@4@5@6 + // > + // > HTTP method to use. Method string `json:"method" default:"POST" options:"POST|GET|PATCH"` // * - Address string `json:"address" default:"" required:"true"` // * + // > @3@4@5@6 + // > + // > URL address to send requests to. + // > Example: `http://localhost:8080/api`.com/v1/events` + Address string `json:"address" required:"true"` // * // > @3@4@5@6 // > @@ -111,7 +126,12 @@ type Config struct { // > @3@4@5@6 // > // > List of HTTP status codes that are considered successful. - SuccessCodes []int `json:"success_codes"` // * + SuccessCodes []int `json:"success_codes" default:"200"` // * + + // > @3@4@5@6 + // > + // > Path or content of a PEM-encoded CA file. + CACert string `json:"ca_cert"` // * // > @3@4@5@6 // > @@ -136,24 +156,31 @@ func (p *Plugin) Start(config pipeline.AnyConfig, params *pipeline.ActionPluginP p.config = config.(*Config) p.logger = params.Logger.Desugar() - // Default success codes to 200 if not specified - if len(p.config.SuccessCodes) == 0 { - p.config.SuccessCodes = []int{200} - } - + addr, isTLS := getAddrFromURL(p.config.Address) p.client = &fasthttp.HostClient{ - Addr: getAddrFromURL(p.config.Address), + Addr: addr, ReadTimeout: p.config.Timeout_, WriteTimeout: p.config.Timeout_, - IsTLS: isURLTLS(p.config.Address), + IsTLS: isTLS, } if p.config.ForceHTTP2 { if err := http2.ConfigureClient(p.client, http2.ClientOpts{}); err != nil { - log.Printf("Server %s does not support HTTP/2: %v\n", p.client.Addr, err) + p.logger.Warn("Server does not support HTTP/2", + zap.String("address", p.client.Addr), + zap.Error(err), + ) } } + if p.config.CACert != "" { + tlsBuilder := xtls.NewConfigBuilder() + if err := tlsBuilder.AppendCARoot(p.config.CACert); err != nil { + p.logger.Fatal("can't append CA root", zap.Error(err)) + } + p.client.TLSConfig = tlsBuilder.Build() + } + p.registerMetrics(params.MetricCtl, p.config.MetricPrefix) p.backoffStrategy = p.newBackoffStrategy() @@ -163,43 +190,39 @@ func (p *Plugin) Start(config pipeline.AnyConfig, params *pipeline.ActionPluginP for name, fieldSelector := range p.config.Params { p.paramFields[name] = cfg.ParseFieldSelector(fieldSelector) } -} -func isURLTLS(rawURL string) bool { - u, err := url.Parse(rawURL) - if err != nil { - return false - } - return u.Scheme == "https" + // Compile URL template + tmpl := template.Must(template.New("").Funcs(template.FuncMap{ + "default": func(defaultValue string, value interface{}) interface{} { + if value == nil || value == "" { + return defaultValue + } + return value + }, + }).Parse(p.config.Address)) + p.addressTmpl = tmpl } -func getAddrFromURL(rawURL string) string { +func getAddrFromURL(rawURL string) (string, bool) { u, err := url.Parse(rawURL) if err != nil { - return "" + return "", false } host := u.Hostname() port := u.Port() + isTLS := u.Scheme == "https" + if port == "" { - if u.Scheme == "https" { + if isTLS { port = "443" } else { port = "80" } } - return host + ":" + port -} - -func isSuccessStatusCode(statusCode int, successCodes []int) bool { - for _, code := range successCodes { - if statusCode == code { - return true - } - } - return false + return host + ":" + port, isTLS } func (p *Plugin) Stop() { @@ -238,19 +261,15 @@ func (p *Plugin) Do(event *pipeline.Event) pipeline.ActionResult { params[name] = value } - // Build the address by replacing {param_name} placeholders. - address := p.config.Address - usedParams := make(map[string]bool) - for name, value := range params { - // Check if param is used in URL before replacing - if strings.Contains(p.config.Address, "{"+name+"}") { - usedParams[name] = true - } - address = strings.ReplaceAll(address, "{"+name+"}", value) + // Build the address using template execution + var buf bytes.Buffer + if err := p.addressTmpl.Execute(&buf, params); err != nil { + p.logger.Fatal("failed to execute URL template", + zap.String("address", p.config.Address), + zap.Error(err), + ) } - - // Encode the event as the request body. - body := []byte(event.Root.EncodeToString()) + address := buf.String() // Measure total request time including retries startTime := time.Now() @@ -262,11 +281,9 @@ func (p *Plugin) Do(event *pipeline.Event) pipeline.ActionResult { req.SetRequestURI(address) - // Add unused params as query string parameters. + // Add all params as query string parameters. for name, value := range params { - if !usedParams[name] { - req.URI().QueryArgs().Add(name, value) - } + req.URI().QueryArgs().Add(name, value) } req.Header.SetMethod(p.config.Method) req.Header.SetContentType(p.config.ContentType) @@ -275,7 +292,6 @@ func (p *Plugin) Do(event *pipeline.Event) pipeline.ActionResult { for key, value := range p.config.Headers { req.Header.Set(key, value) } - req.SetBodyRaw(body) resp := fasthttp.AcquireResponse() defer fasthttp.ReleaseResponse(resp) @@ -291,15 +307,20 @@ func (p *Plugin) Do(event *pipeline.Event) pipeline.ActionResult { } statusCode := resp.Header.StatusCode() - if !isSuccessStatusCode(statusCode, p.config.SuccessCodes) { - err := fmt.Errorf("non-success status code: %d", statusCode) + if !slices.Contains(p.config.SuccessCodes, statusCode) { p.logger.Error("http request returned non-success status code", zap.String("address", address), zap.String("method", p.config.Method), zap.Int("status_code", statusCode), + ) + p.logger.Debug("error response body", + zap.Any("request", params), zap.ByteString("response", resp.Body()), ) + p.requestsMetric.WithLabelValues("not_success").Inc() + + err := fmt.Errorf("non-success status code: %d", statusCode) return err }