diff --git a/.chloggen/add-multiple-endpoints-support-httpcheckreceiver.yaml b/.chloggen/add-multiple-endpoints-support-httpcheckreceiver.yaml new file mode 100644 index 000000000000..49aeb87acef9 --- /dev/null +++ b/.chloggen/add-multiple-endpoints-support-httpcheckreceiver.yaml @@ -0,0 +1,13 @@ +# Use this changelog template to create an entry for release notes. + +# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix' +change_type: enhancement + +# The name of the component, or a single word describing the area of concern, (e.g. filelogreceiver) +component: httpcheckreceiver + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: "Added support for specifying multiple endpoints in the `httpcheckreceiver` using the `endpoints` field. Users can now monitor multiple URLs with a single configuration block, improving flexibility and reducing redundancy." + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [37121] diff --git a/receiver/httpcheckreceiver/README.md b/receiver/httpcheckreceiver/README.md index 3ede7c79de99..d047236432bf 100644 --- a/receiver/httpcheckreceiver/README.md +++ b/receiver/httpcheckreceiver/README.md @@ -35,26 +35,46 @@ The following configuration settings are available: Each target has the following properties: -- `endpoint` (required): the URL to be monitored -- `method` (optional, default: `GET`): The HTTP method used to call the endpoint +- `endpoint` (optional): A single URL to be monitored. +- `endpoints` (optional): A list of URLs to be monitored. +- `method` (optional, default: `GET`): The HTTP method used to call the endpoint or endpoints. -Additionally, each target supports the client configuration options of [confighttp]. +At least one of `endpoint` or `endpoints` must be specified. Additionally, each target supports the client configuration options of [confighttp]. ### Example Configuration ```yaml receivers: httpcheck: + collection_interval: 30s targets: - - endpoint: http://endpoint:80 - method: GET - - endpoint: http://localhost:8080/health - method: GET - - endpoint: http://localhost:8081/health - method: POST + - method: "GET" + endpoints: + - "https://opentelemetry.io" + - method: "GET" + endpoints: + - "http://localhost:8080/hello1" + - "http://localhost:8080/hello2" headers: - test-header: "test-value" - collection_interval: 10s + Authorization: "Bearer " + - method: "GET" + endpoint: "http://localhost:8080/hello" + headers: + Authorization: "Bearer " +processors: + batch: + send_batch_max_size: 1000 + send_batch_size: 100 + timeout: 10s +exporters: + debug: + verbosity: detailed +service: + pipelines: + metrics: + receivers: [httpcheck] + processors: [batch] + exporters: [debug] ``` ## Metrics diff --git a/receiver/httpcheckreceiver/config.go b/receiver/httpcheckreceiver/config.go index e51da527952c..6bbd7ce963bc 100644 --- a/receiver/httpcheckreceiver/config.go +++ b/receiver/httpcheckreceiver/config.go @@ -17,8 +17,8 @@ import ( // Predefined error responses for configuration validation failures var ( - errMissingEndpoint = errors.New(`"endpoint" must be specified`) errInvalidEndpoint = errors.New(`"endpoint" must be in the form of ://[:]`) + errMissingEndpoint = errors.New("at least one of 'endpoint' or 'endpoints' must be specified") ) // Config defines the configuration for the various elements of the receiver agent. @@ -28,20 +28,32 @@ type Config struct { Targets []*targetConfig `mapstructure:"targets"` } +// targetConfig defines configuration for individual HTTP checks. type targetConfig struct { confighttp.ClientConfig `mapstructure:",squash"` - Method string `mapstructure:"method"` + Method string `mapstructure:"method"` + Endpoints []string `mapstructure:"endpoints"` // Field for a list of endpoints } -// Validate validates the configuration by checking for missing or invalid fields +// Validate validates an individual targetConfig. func (cfg *targetConfig) Validate() error { var err error - if cfg.Endpoint == "" { + // Ensure at least one of 'endpoint' or 'endpoints' is specified. + if cfg.ClientConfig.Endpoint == "" && len(cfg.Endpoints) == 0 { err = multierr.Append(err, errMissingEndpoint) - } else { - _, parseErr := url.ParseRequestURI(cfg.Endpoint) - if parseErr != nil { + } + + // Validate the single endpoint in ClientConfig. + if cfg.ClientConfig.Endpoint != "" { + if _, parseErr := url.ParseRequestURI(cfg.ClientConfig.Endpoint); parseErr != nil { + err = multierr.Append(err, fmt.Errorf("%s: %w", errInvalidEndpoint.Error(), parseErr)) + } + } + + // Validate each endpoint in the Endpoints list. + for _, endpoint := range cfg.Endpoints { + if _, parseErr := url.ParseRequestURI(endpoint); parseErr != nil { err = multierr.Append(err, fmt.Errorf("%s: %w", errInvalidEndpoint.Error(), parseErr)) } } @@ -49,14 +61,16 @@ func (cfg *targetConfig) Validate() error { return err } -// Validate validates the configuration by checking for missing or invalid fields +// Validate validates the top-level Config by checking each targetConfig. func (cfg *Config) Validate() error { var err error + // Ensure at least one target is configured. if len(cfg.Targets) == 0 { err = multierr.Append(err, errors.New("no targets configured")) } + // Validate each targetConfig. for _, target := range cfg.Targets { err = multierr.Append(err, target.Validate()) } diff --git a/receiver/httpcheckreceiver/config_test.go b/receiver/httpcheckreceiver/config_test.go index e7140f236ecd..b98b60110bd2 100644 --- a/receiver/httpcheckreceiver/config_test.go +++ b/receiver/httpcheckreceiver/config_test.go @@ -105,6 +105,98 @@ func TestValidate(t *testing.T) { }, expectedErr: nil, }, + { + desc: "missing both endpoint and endpoints", + cfg: &Config{ + Targets: []*targetConfig{ + { + ClientConfig: confighttp.ClientConfig{}, + }, + }, + ControllerConfig: scraperhelper.NewDefaultControllerConfig(), + }, + expectedErr: multierr.Combine( + errMissingEndpoint, + ), + }, + { + desc: "invalid single endpoint", + cfg: &Config{ + Targets: []*targetConfig{ + { + ClientConfig: confighttp.ClientConfig{ + Endpoint: "invalid://endpoint: 12efg", + }, + }, + }, + ControllerConfig: scraperhelper.NewDefaultControllerConfig(), + }, + expectedErr: multierr.Combine( + fmt.Errorf("%w: %s", errInvalidEndpoint, `parse "invalid://endpoint: 12efg": invalid port ": 12efg" after host`), + ), + }, + { + desc: "invalid endpoint in endpoints list", + cfg: &Config{ + Targets: []*targetConfig{ + { + Endpoints: []string{ + "https://valid.endpoint", + "invalid://endpoint: 12efg", + }, + }, + }, + ControllerConfig: scraperhelper.NewDefaultControllerConfig(), + }, + expectedErr: multierr.Combine( + fmt.Errorf("%w: %s", errInvalidEndpoint, `parse "invalid://endpoint: 12efg": invalid port ": 12efg" after host`), + ), + }, + { + desc: "missing scheme in single endpoint", + cfg: &Config{ + Targets: []*targetConfig{ + { + ClientConfig: confighttp.ClientConfig{ + Endpoint: "www.opentelemetry.io/docs", + }, + }, + }, + ControllerConfig: scraperhelper.NewDefaultControllerConfig(), + }, + expectedErr: multierr.Combine( + fmt.Errorf("%w: %s", errInvalidEndpoint, `parse "www.opentelemetry.io/docs": invalid URI for request`), + ), + }, + { + desc: "valid single endpoint", + cfg: &Config{ + Targets: []*targetConfig{ + { + ClientConfig: confighttp.ClientConfig{ + Endpoint: "https://opentelemetry.io", + }, + }, + }, + ControllerConfig: scraperhelper.NewDefaultControllerConfig(), + }, + expectedErr: nil, + }, + { + desc: "valid endpoints list", + cfg: &Config{ + Targets: []*targetConfig{ + { + Endpoints: []string{ + "https://opentelemetry.io", + "https://opentelemetry.io:80/docs", + }, + }, + }, + ControllerConfig: scraperhelper.NewDefaultControllerConfig(), + }, + expectedErr: nil, + }, } for _, tc := range testCases { diff --git a/receiver/httpcheckreceiver/scraper.go b/receiver/httpcheckreceiver/scraper.go index e464410f8a15..3408800136da 100644 --- a/receiver/httpcheckreceiver/scraper.go +++ b/receiver/httpcheckreceiver/scraper.go @@ -32,19 +32,43 @@ type httpcheckScraper struct { mb *metadata.MetricsBuilder } -// start starts the scraper by creating a new HTTP Client on the scraper +// start initializes the scraper by creating HTTP clients for each endpoint. func (h *httpcheckScraper) start(ctx context.Context, host component.Host) (err error) { + var expandedTargets []*targetConfig + for _, target := range h.cfg.Targets { - client, clentErr := target.ToClient(ctx, host, h.settings) - if clentErr != nil { - err = multierr.Append(err, clentErr) + // Create a unified list of endpoints + var allEndpoints []string + if len(target.Endpoints) > 0 { + allEndpoints = append(allEndpoints, target.Endpoints...) // Add all endpoints + } + if target.ClientConfig.Endpoint != "" { + allEndpoints = append(allEndpoints, target.ClientConfig.Endpoint) // Add single endpoint + } + + // Process each endpoint in the unified list + for _, endpoint := range allEndpoints { + client, clientErr := target.ToClient(ctx, host, h.settings) + if clientErr != nil { + h.settings.Logger.Error("failed to initialize HTTP client", zap.String("endpoint", endpoint), zap.Error(clientErr)) + err = multierr.Append(err, clientErr) + continue + } + + // Clone the target and assign the specific endpoint + targetClone := *target + targetClone.ClientConfig.Endpoint = endpoint + + h.clients = append(h.clients, client) + expandedTargets = append(expandedTargets, &targetClone) // Add the cloned target to expanded targets } - h.clients = append(h.clients, client) } + + h.cfg.Targets = expandedTargets // Replace targets with expanded targets return } -// scrape connects to the endpoint and produces metrics based on the response +// scrape performs the HTTP checks and records metrics based on responses. func (h *httpcheckScraper) scrape(ctx context.Context) (pmetric.Metrics, error) { if len(h.clients) == 0 { return pmetric.NewMetrics(), errClientNotInit @@ -60,29 +84,64 @@ func (h *httpcheckScraper) scrape(ctx context.Context) (pmetric.Metrics, error) now := pcommon.NewTimestampFromTime(time.Now()) - req, err := http.NewRequestWithContext(ctx, h.cfg.Targets[targetIndex].Method, h.cfg.Targets[targetIndex].Endpoint, http.NoBody) + req, err := http.NewRequestWithContext( + ctx, + h.cfg.Targets[targetIndex].Method, + h.cfg.Targets[targetIndex].ClientConfig.Endpoint, // Use the ClientConfig.Endpoint + http.NoBody, + ) if err != nil { h.settings.Logger.Error("failed to create request", zap.Error(err)) return } + // Add headers to the request + for key, value := range h.cfg.Targets[targetIndex].Headers { + req.Header.Set(key, value.String()) // Convert configopaque.String to string + } + + // Send the request and measure response time start := time.Now() resp, err := targetClient.Do(req) mux.Lock() - h.mb.RecordHttpcheckDurationDataPoint(now, time.Since(start).Milliseconds(), h.cfg.Targets[targetIndex].Endpoint) + h.mb.RecordHttpcheckDurationDataPoint( + now, + time.Since(start).Milliseconds(), + h.cfg.Targets[targetIndex].ClientConfig.Endpoint, // Use the correct endpoint + ) statusCode := 0 if err != nil { - h.mb.RecordHttpcheckErrorDataPoint(now, int64(1), h.cfg.Targets[targetIndex].Endpoint, err.Error()) + h.mb.RecordHttpcheckErrorDataPoint( + now, + int64(1), + h.cfg.Targets[targetIndex].ClientConfig.Endpoint, + err.Error(), + ) } else { statusCode = resp.StatusCode } + // Record HTTP status class metrics for class, intVal := range httpResponseClasses { if statusCode/100 == intVal { - h.mb.RecordHttpcheckStatusDataPoint(now, int64(1), h.cfg.Targets[targetIndex].Endpoint, int64(statusCode), req.Method, class) + h.mb.RecordHttpcheckStatusDataPoint( + now, + int64(1), + h.cfg.Targets[targetIndex].ClientConfig.Endpoint, + int64(statusCode), + req.Method, + class, + ) } else { - h.mb.RecordHttpcheckStatusDataPoint(now, int64(0), h.cfg.Targets[targetIndex].Endpoint, int64(statusCode), req.Method, class) + h.mb.RecordHttpcheckStatusDataPoint( + now, + int64(0), + h.cfg.Targets[targetIndex].ClientConfig.Endpoint, + int64(statusCode), + req.Method, + class, + ) } } mux.Unlock() @@ -90,7 +149,6 @@ func (h *httpcheckScraper) scrape(ctx context.Context) (pmetric.Metrics, error) } wg.Wait() - return h.mb.Emit(), nil }