Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .chloggen/42256.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# 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: extension/health_check

# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
note: "Added extension.healthcheck.disableCompatibilityWrapper feature gate to enable v2 component status reporting in healthcheckextension while maintaining backward compatibility by default."

# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists.
issues: [42256]

# (Optional) One or more lines of additional information to render under the primary note.
# These lines will be padded with 2 spaces and then inserted directly into the document.
# Use pipe (|) for multiline entries.
subtext:

# If your change doesn't affect end users or the exported elements of any package,
# you should instead start your pull request title with [chore] or use the "Skip Changelog" label.
# Optional: The change log or logs in which this entry should be included.
# e.g. '[user]' or '[user, api]'
# Include 'user' if the change is relevant to end users.
# Include 'api' if there is a change to a library API.
# Default: '[user]'
change_logs: []
48 changes: 47 additions & 1 deletion extension/healthcheckextension/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Health Check

> ℹ️ **Migration Notice** ℹ️
>
> This extension is migrating to use component status reporting for health checks
> while maintaining full backward compatibility. See the [Backward Compatibility](#backward-compatibility)
> section for details about feature gates and migration options.

> ⚠️⚠️⚠️ **Warning** ⚠️⚠️⚠️
>
> The `check_collector_pipeline` feature of this extension is not working as expected. It
Expand Down Expand Up @@ -53,5 +59,45 @@ extensions:
unhealthy: I'm bad!
```

The full list of settings exposed for this exporter is documented in [config.go](./config.go)
The full list of settings exposed for this exporter is documented in [LegacyConfig in config.go](../../internal/healthcheck/internal/http/config.go#L24)
with detailed sample configurations in [testdata/config.yaml](./testdata/config.yaml).

## Backward Compatibility

[Linked issue](https://github.com/open-telemetry/opentelemetry-collector-contrib/issues/42256).

This extension maintains full backward compatibility with the original Ready/NotReady behavior by keeping the legacy implementation active unless a feature gate is enabled.

### Feature Gate: `extension.healthcheck.disableCompatibilityWrapper`

- **Default**: Disabled (false)
- **Purpose**: Switches the extension to the shared healthcheck implementation that reports status from component events
- **When enabled**: Health status is determined by component status events (v2 behavior)
- **When disabled**: Ready/NotReady calls directly control health endpoint status using the legacy implementation

#### Usage

To use the new event-driven behavior:

```bash
# Set the feature gate to true
--feature-gates=extension.healthcheck.disableCompatibilityWrapper=true
```

#### Migration Timeline

1. **Current**: Compatibility wrapper enabled by default - no breaking changes.
2. **Future**: Feature gate will be removed, compatibility wrapper will be permanently disabled.
3. **Recommended**: Test your setup with the feature gate enabled to prepare for future versions.

#### Ready/NotReady Behavior

**Legacy Implementation (Default)**
- `Ready()` → Health endpoint returns 200 OK
- `NotReady()` → Health endpoint returns 503 Service Unavailable
- Behavior identical to original extension

**Shared Healthcheck Implementation (Feature gate enabled)**
- `Ready()`/`NotReady()` → Used for pipeline lifecycle only
- Health status determined by component status events
- Behavior similar to healthcheckv2extension
71 changes: 6 additions & 65 deletions extension/healthcheckextension/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,71 +4,12 @@
package healthcheckextension // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckextension"

import (
"errors"
"strings"
"time"

"go.opentelemetry.io/collector/component"
"go.opentelemetry.io/collector/config/confighttp"
)

type ResponseBodySettings struct {
// Healthy represents the body of the response returned when the collector is healthy.
// The default value is ""
Healthy string `mapstructure:"healthy"`

// Unhealthy represents the body of the response returned when the collector is unhealthy.
// The default value is ""
Unhealthy string `mapstructure:"unhealthy"`
}

// Config has the configuration for the extension enabling the health check
// extension, used to report the health status of the service.
type Config struct {
confighttp.ServerConfig `mapstructure:",squash"`

// Path represents the path the health check service will serve.
// The default path is "/".
Path string `mapstructure:"path"`

// ResponseBody represents the body of the response returned by the health check service.
// This overrides the default response that it would return.
ResponseBody *ResponseBodySettings `mapstructure:"response_body"`

// CheckCollectorPipeline contains the list of settings of collector pipeline health check
CheckCollectorPipeline checkCollectorPipelineSettings `mapstructure:"check_collector_pipeline"`
}

var _ component.Config = (*Config)(nil)
var (
errNoEndpointProvided = errors.New("bad config: endpoint must be specified")
errInvalidExporterFailureThresholdProvided = errors.New("bad config: exporter_failure_threshold expects a positive number")
errInvalidPath = errors.New("bad config: path must start with /")
"github.com/open-telemetry/opentelemetry-collector-contrib/internal/healthcheck"
)

// Validate checks if the extension configuration is valid
func (cfg *Config) Validate() error {
_, err := time.ParseDuration(cfg.CheckCollectorPipeline.Interval)
if err != nil {
return err
}
if cfg.Endpoint == "" {
return errNoEndpointProvided
}
if cfg.CheckCollectorPipeline.ExporterFailureThreshold <= 0 {
return errInvalidExporterFailureThresholdProvided
}
if !strings.HasPrefix(cfg.Path, "/") {
return errInvalidPath
}
return nil
}
// Config is an alias to the shared healthcheck.Config to keep the extensions in lockstep.
// This ensures complete compatibility and eliminates the need for translation layers.
type Config = healthcheck.Config

type checkCollectorPipelineSettings struct {
// Enabled indicates whether to not enable collector pipeline check.
Enabled bool `mapstructure:"enabled"`
// Interval the time range to check healthy status of collector pipeline
Interval string `mapstructure:"interval"`
// ExporterFailureThreshold is the threshold of exporter failure numbers during the Interval
ExporterFailureThreshold int `mapstructure:"exporter_failure_threshold"`
}
// Type alias for backward compatibility
type ResponseBodySettings = healthcheck.ResponseBodyConfig
126 changes: 104 additions & 22 deletions extension/healthcheckextension/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,26 @@ package healthcheckextension
import (
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/collector/component"
"go.opentelemetry.io/collector/config/configgrpc"
"go.opentelemetry.io/collector/config/confighttp"
"go.opentelemetry.io/collector/config/confignet"
"go.opentelemetry.io/collector/config/configoptional"
"go.opentelemetry.io/collector/config/configtls"
"go.opentelemetry.io/collector/confmap/confmaptest"
"go.opentelemetry.io/collector/confmap/xconfmap"
"go.opentelemetry.io/collector/extension/extensiontest"
"go.opentelemetry.io/collector/featuregate"

"github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckextension/internal/metadata"
"github.com/open-telemetry/opentelemetry-collector-contrib/internal/healthcheck"
)

func TestLoadConfig(t *testing.T) {
func TestLoadConfigLegacy(t *testing.T) {
t.Parallel()

tests := []struct {
Expand All @@ -34,51 +40,52 @@ func TestLoadConfig(t *testing.T) {
{
id: component.NewIDWithName(metadata.Type, "1"),
expected: &Config{
ServerConfig: confighttp.ServerConfig{
Endpoint: "localhost:13",
TLS: configoptional.Some(configtls.ServerConfig{
Config: configtls.Config{
CAFile: "/path/to/ca",
CertFile: "/path/to/cert",
KeyFile: "/path/to/key",
},
}),
LegacyConfig: healthcheck.HTTPLegacyConfig{
ServerConfig: confighttp.ServerConfig{
Endpoint: "localhost:13",
TLS: configoptional.Some(configtls.ServerConfig{
Config: configtls.Config{
CAFile: "/path/to/ca",
CertFile: "/path/to/cert",
KeyFile: "/path/to/key",
},
}),
},
Path: "/",
CheckCollectorPipeline: &healthcheck.CheckCollectorPipelineConfig{
Enabled: false,
Interval: "5m",
ExporterFailureThreshold: 5,
},
},
CheckCollectorPipeline: defaultCheckCollectorPipelineSettings(),
Path: "/",
ResponseBody: nil,
},
},
{
id: component.NewIDWithName(metadata.Type, "missingendpoint"),
expectedErr: errNoEndpointProvided,
},
{
id: component.NewIDWithName(metadata.Type, "invalidthreshold"),
expectedErr: errInvalidExporterFailureThresholdProvided,
expectedErr: healthcheck.ErrHTTPEndpointRequired,
},
{
id: component.NewIDWithName(metadata.Type, "invalidpath"),
expectedErr: errInvalidPath,
expectedErr: healthcheck.ErrInvalidPath,
},
{
id: component.NewIDWithName(metadata.Type, "response-body"),
expected: func() component.Config {
cfg := NewFactory().CreateDefaultConfig().(*Config)
cfg.ResponseBody = &ResponseBodySettings{
cfg.ResponseBody = &healthcheck.ResponseBodyConfig{
Healthy: "I'm OK",
Unhealthy: "I'm not well",
}
return cfg
}(),
},
}

for _, tt := range tests {
t.Run(tt.id.String(), func(t *testing.T) {
cm, err := confmaptest.LoadConf(filepath.Join("testdata", "config.yaml"))
require.NoError(t, err)
factory := NewFactory()
cfg := factory.CreateDefaultConfig()
cfg := NewFactory().CreateDefaultConfig()
sub, err := cm.Sub(tt.id.String())
require.NoError(t, err)
require.NoError(t, sub.Unmarshal(cfg))
Expand All @@ -91,3 +98,78 @@ func TestLoadConfig(t *testing.T) {
})
}
}

func TestLoadConfigV2WithoutGate(t *testing.T) {
cm, err := confmaptest.LoadConf(filepath.Join("testdata", "config.yaml"))
require.NoError(t, err)

cfg := NewFactory().CreateDefaultConfig()
sub, err := cm.Sub("health_check/v2-http-only")
require.NoError(t, err)
require.NoError(t, sub.Unmarshal(cfg))
assert.NotNil(t, cfg.(*Config).HTTPConfig)

// Without the feature gate, v2 config is ignored and legacy extension is created.
// This allows users to have both legacy and v2 configs for easier migration.
f := NewFactory()
ext, err := f.Create(t.Context(), extensiontest.NewNopSettings(f.Type()), cfg)
require.NoError(t, err)
assert.IsType(t, &healthCheckExtension{}, ext, "should create legacy extension when gate is disabled")
}

func TestLoadConfigV2WithGate(t *testing.T) {
prev := disableCompatibilityWrapperGate.IsEnabled()
require.NoError(t, featuregate.GlobalRegistry().Set(disableCompatibilityWrapperGate.ID(), true))
t.Cleanup(func() {
require.NoError(t, featuregate.GlobalRegistry().Set(disableCompatibilityWrapperGate.ID(), prev))
})

cm, err := confmaptest.LoadConf(filepath.Join("testdata", "config.yaml"))
require.NoError(t, err)

cfg := NewFactory().CreateDefaultConfig().(*Config)
sub, err := cm.Sub("health_check/v2-both-protocols")
require.NoError(t, err)
require.NoError(t, sub.Unmarshal(cfg))

assert.NoError(t, xconfmap.Validate(cfg))
assert.Equal(t, &Config{
LegacyConfig: healthcheck.HTTPLegacyConfig{
ServerConfig: confighttp.ServerConfig{
Endpoint: "localhost:13133",
},
Path: "/",
CheckCollectorPipeline: &healthcheck.CheckCollectorPipelineConfig{
Enabled: false,
Interval: "5m",
ExporterFailureThreshold: 5,
},
},
HTTPConfig: &healthcheck.HTTPConfig{
ServerConfig: confighttp.ServerConfig{
Endpoint: "localhost:13133",
},
Status: healthcheck.PathConfig{
Enabled: true,
Path: "/status",
},
Config: healthcheck.PathConfig{
Enabled: true,
Path: "/config",
},
},
GRPCConfig: &healthcheck.GRPCConfig{
ServerConfig: configgrpc.ServerConfig{
NetAddr: confignet.AddrConfig{
Endpoint: "localhost:13132",
Transport: confignet.TransportTypeTCP,
},
},
},
ComponentHealthConfig: &healthcheck.ComponentHealthConfig{
IncludePermanent: true,
IncludeRecoverable: true,
RecoveryDuration: time.Minute,
},
}, cfg)
}
Loading