Skip to content

Commit

Permalink
Added support for endpoints in httpcheckreceiver (#37265)
Browse files Browse the repository at this point in the history
#### Description
This PR enhances the `httpcheckreceiver` by adding support for multiple
endpoints (`endpoints`). Users can now specify a list of endpoints in
addition to a single `endpoint` for each target. This improves
flexibility and reduces redundancy when monitoring multiple similar
endpoints.

Additional changes include:
- Updates to `config.go` to handle `endpoints`.
- Updates to `scraper.go` to iterate over and scrape all specified
endpoints.
- Added unit tests for the new functionality in `config_test.go` and
`scraper_test.go`.
- Updated documentation (`README.md`) to reflect the changes.

<!-- Issue number (e.g. #1234) or full URL to issue, if applicable. -->
#### Link to Tracking Issue
Fixes #37121

<!-- Describe what testing was performed and which tests were added. -->
#### Testing
- All existing and new tests pass.
- Tested the `httpcheckreceiver` manually using the following
configuration:
```yaml
receivers:
  httpcheck:
    collection_interval: 30s
    targets:
      - method: "GET"
        endpoints:
          - "https://opentelemetry.io"
      - method: "GET"
        endpoints: 
          - "http://localhost:8080/hello"
          - "http://localhost:8080/hello"
        headers:
          Authorization: "Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJqYXZhaW51c2UiLCJleHAiOjE3MzcwMzMzMTcsImlhdCI6MTczNzAxNTMxN30.qNb_hckvlqfWmnnaw2xP9ie2AKGO6ljzGxcMotoFZg3CwcYSTGu7VE6ERsvX_nHlcZOYZHgPc7_9WSBlCZ9M_w"   
      - method: "GET"
        endpoint: "http://localhost:8080/hello"
        headers:
          Authorization: "Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJqYXZhaW51c2UiLCJleHAiOjE3MzcwMzMzMTcsImlhdCI6MTczNzAxNTMxN30.qNb_hckvlqfWmnnaw2xP9ie2AKGO6ljzGxcMotoFZg3CwcYSTGu7VE6ERsvX_nHlcZOYZHgPc7_9WSBlCZ9M_w"
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]
```

#### **Documentation**
Describe any documentation changes or additions:
```markdown
<!-- Describe the documentation added. -->
#### Documentation
- Updated the `README.md` to include examples for `endpoints`.
- Verified `documentation.md` for metric output consistency.

---------

Co-authored-by: Antoine Toulme <[email protected]>
  • Loading branch information
VenuEmmadi and atoulme authored Jan 24, 2025
1 parent c4abcb9 commit 27cab50
Show file tree
Hide file tree
Showing 5 changed files with 228 additions and 31 deletions.
13 changes: 13 additions & 0 deletions .chloggen/add-multiple-endpoints-support-httpcheckreceiver.yaml
Original file line number Diff line number Diff line change
@@ -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]
42 changes: 31 additions & 11 deletions receiver/httpcheckreceiver/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <your_bearer_token>"
- method: "GET"
endpoint: "http://localhost:8080/hello"
headers:
Authorization: "Bearer <your_bearer_token>"
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
Expand Down
30 changes: 22 additions & 8 deletions receiver/httpcheckreceiver/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <scheme>://<hostname>[:<port>]`)
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.
Expand All @@ -28,35 +28,49 @@ 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))
}
}

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())
}
Expand Down
92 changes: 92 additions & 0 deletions receiver/httpcheckreceiver/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
82 changes: 70 additions & 12 deletions receiver/httpcheckreceiver/scraper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -60,37 +84,71 @@ 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()
}(client, idx)
}

wg.Wait()

return h.mb.Emit(), nil
}

Expand Down

0 comments on commit 27cab50

Please sign in to comment.