From d53bb4e915ecf3b21660477463c81d320b9cb160 Mon Sep 17 00:00:00 2001 From: Matthew Wear Date: Wed, 10 Jul 2024 00:46:21 -0700 Subject: [PATCH] [extension/healthcheckv2] Add HTTP service (#33528) **Description:** This PR is the third in a series to decompose https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/30673 into more manageable pieces for review. This PR introduces the HTTP service which builds upon the aggregation logic added in the [previous PR](https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/32695). Following this will be a PR to add a gRPC health check service. A summary of the changes is below: - http service based on component status - supports legacy behavior and config; to be deprecated - overall collector health can be monitored as well as pipeline health - additionally the verbosity of the response can be controlled by passing a `verbose` query parameter - adds optional endpoint to retrieve the config of the running collector - this is currently unredacted JSON and is opt-in Note, that there will be a follow up PR to add the gRPC service. This will be relevant when reviewing the extension code. It is setup to manage a slice of subcomponents. The HTTP and gRPC services are the subcomponents to be managed, and both implement the `Component` interface. See the [reference PR](https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/30673) for details. I've provided some examples below to help get an idea of what the responses look like when serialized as JSON. **Collector Health Example Response** Below is an example verbose response for the overall collector health. Note, the top level fields correspond to the collector overall, the next level corresponds to the pipelines, and the level below that is the health of the individual components in each pipeline. ```json { "start_time": "2024-01-18T17:27:12.570394-08:00", "healthy": true, "status": "StatusRecoverableError", "error": "rpc error: code = ResourceExhausted desc = resource exhausted", "status_time": "2024-01-18T17:27:32.572301-08:00", "components": { "extensions": { "healthy": true, "status": "StatusOK", "status_time": "2024-01-18T17:27:12.570428-08:00", "components": { "extension:healthcheckv2": { "healthy": true, "status": "StatusOK", "status_time": "2024-01-18T17:27:12.570428-08:00" } } }, "pipeline:metrics/grpc": { "healthy": true, "status": "StatusRecoverableError", "error": "rpc error: code = ResourceExhausted desc = resource exhausted", "status_time": "2024-01-18T17:27:32.572301-08:00", "components": { "exporter:otlp/staging": { "healthy": true, "status": "StatusRecoverableError", "error": "rpc error: code = ResourceExhausted desc = resource exhausted", "status_time": "2024-01-18T17:27:32.572301-08:00" }, "processor:batch": { "healthy": true, "status": "StatusOK", "status_time": "2024-01-18T17:27:12.571132-08:00" }, "receiver:otlp": { "healthy": true, "status": "StatusOK", "status_time": "2024-01-18T17:27:12.571576-08:00" } } }, "pipeline:traces/http": { "healthy": true, "status": "StatusOK", "status_time": "2024-01-18T17:27:12.571625-08:00", "components": { "exporter:otlphttp/staging": { "healthy": true, "status": "StatusOK", "status_time": "2024-01-18T17:27:12.571615-08:00" }, "processor:batch": { "healthy": true, "status": "StatusOK", "status_time": "2024-01-18T17:27:12.571621-08:00" }, "receiver:otlp": { "healthy": true, "status": "StatusOK", "status_time": "2024-01-18T17:27:12.571625-08:00" } } } } } ``` **Pipeline Health Example Response** This is an example verbose response for the `traces/http` pipeline. The top level corresponds to the health of the pipeline, and the second level contains the health of the individual components that make up the pipeline. ```json { "start_time": "2024-01-18T17:27:12.570394-08:00", "healthy": true, "status": "StatusRecoverableError", "error": "rpc error: code = ResourceExhausted desc = resource exhausted", "status_time": "2024-01-18T17:27:32.572301-08:00", "components": { "extensions": { "healthy": true, "status": "StatusOK", "status_time": "2024-01-18T17:27:12.570428-08:00", "components": { "extension:healthcheckv2": { "healthy": true, "status": "StatusOK", "status_time": "2024-01-18T17:27:12.570428-08:00" } } }, "pipeline:metrics/grpc": { "healthy": true, "status": "StatusRecoverableError", "error": "rpc error: code = ResourceExhausted desc = resource exhausted", "status_time": "2024-01-18T17:27:32.572301-08:00", "components": { "exporter:otlp/staging": { "healthy": true, "status": "StatusRecoverableError", "error": "rpc error: code = ResourceExhausted desc = resource exhausted", "status_time": "2024-01-18T17:27:32.572301-08:00" }, "processor:batch": { "healthy": true, "status": "StatusOK", "status_time": "2024-01-18T17:27:12.571132-08:00" }, "receiver:otlp": { "healthy": true, "status": "StatusOK", "status_time": "2024-01-18T17:27:12.571576-08:00" } } }, "pipeline:traces/http": { "healthy": true, "status": "StatusOK", "status_time": "2024-01-18T17:27:12.571625-08:00", "components": { "exporter:otlphttp/staging": { "healthy": true, "status": "StatusOK", "status_time": "2024-01-18T17:27:12.571615-08:00" }, "processor:batch": { "healthy": true, "status": "StatusOK", "status_time": "2024-01-18T17:27:12.571621-08:00" }, "receiver:otlp": { "healthy": true, "status": "StatusOK", "status_time": "2024-01-18T17:27:12.571625-08:00" } } } } } ``` **Link to tracking Issue:** #26661 **Testing:** Units / manual **Documentation:** Comments, etc --------- Signed-off-by: Alex Boten <223565+codeboten@users.noreply.github.com> Co-authored-by: Alex Boten <223565+codeboten@users.noreply.github.com> --- .chloggen/healthcheckv2-http.yaml | 27 + .../healthcheckv2extension/factory_test.go | 9 +- .../internal/http/handlers.go | 44 + .../internal/http/package_test.go | 14 + .../internal/http/responders.go | 156 + .../internal/http/responders_test.go | 37 + .../internal/http/serialization.go | 95 + .../internal/http/server.go | 125 + .../internal/http/server_test.go | 3146 +++++++++++++++++ .../internal/http/testdata/config.json | 1 + .../internal/http/testdata/config.yaml | 23 + 11 files changed, 3674 insertions(+), 3 deletions(-) create mode 100644 .chloggen/healthcheckv2-http.yaml create mode 100644 extension/healthcheckv2extension/internal/http/handlers.go create mode 100644 extension/healthcheckv2extension/internal/http/package_test.go create mode 100644 extension/healthcheckv2extension/internal/http/responders.go create mode 100644 extension/healthcheckv2extension/internal/http/responders_test.go create mode 100644 extension/healthcheckv2extension/internal/http/serialization.go create mode 100644 extension/healthcheckv2extension/internal/http/server.go create mode 100644 extension/healthcheckv2extension/internal/http/server_test.go create mode 100644 extension/healthcheckv2extension/internal/http/testdata/config.json create mode 100644 extension/healthcheckv2extension/internal/http/testdata/config.yaml diff --git a/.chloggen/healthcheckv2-http.yaml b/.chloggen/healthcheckv2-http.yaml new file mode 100644 index 000000000000..839253079df7 --- /dev/null +++ b/.chloggen/healthcheckv2-http.yaml @@ -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: 'healthcheckv2extension' + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: Add HTTP service to healthcheckv2 + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [26661] + +# (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: [] diff --git a/extension/healthcheckv2extension/factory_test.go b/extension/healthcheckv2extension/factory_test.go index 7f433f2c3161..33ae2e3b0384 100644 --- a/extension/healthcheckv2extension/factory_test.go +++ b/extension/healthcheckv2extension/factory_test.go @@ -54,7 +54,9 @@ func TestCreateDefaultConfig(t *testing.T) { }, cfg) assert.NoError(t, componenttest.CheckConfigStruct(cfg)) - ext, err := createExtension(context.Background(), extensiontest.NewNopSettings(), cfg) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ext, err := createExtension(ctx, extensiontest.NewNopSettings(), cfg) require.NoError(t, err) require.NotNil(t, ext) } @@ -62,8 +64,9 @@ func TestCreateDefaultConfig(t *testing.T) { func TestCreateExtension(t *testing.T) { cfg := createDefaultConfig().(*Config) cfg.Endpoint = testutil.GetAvailableLocalAddress(t) - - ext, err := createExtension(context.Background(), extensiontest.NewNopSettings(), cfg) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ext, err := createExtension(ctx, extensiontest.NewNopSettings(), cfg) require.NoError(t, err) require.NotNil(t, ext) } diff --git a/extension/healthcheckv2extension/internal/http/handlers.go b/extension/healthcheckv2extension/internal/http/handlers.go new file mode 100644 index 000000000000..186add81504b --- /dev/null +++ b/extension/healthcheckv2extension/internal/http/handlers.go @@ -0,0 +1,44 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package http // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckv2extension/internal/http" + +import ( + "net/http" + + "github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckv2extension/internal/status" +) + +func (s *Server) statusHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pipeline := r.URL.Query().Get("pipeline") + verbose := r.URL.Query().Has("verbose") && r.URL.Query().Get("verbose") != "false" + st, ok := s.aggregator.AggregateStatus(status.Scope(pipeline), status.Verbosity(verbose)) + + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + + if err := s.responder.respond(st, w); err != nil { + s.telemetry.Logger.Warn(err.Error()) + } + }) +} + +func (s *Server) configHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + conf := s.colconf.Load() + + if conf == nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(conf.([]byte)); err != nil { + s.telemetry.Logger.Warn(err.Error()) + } + }) +} diff --git a/extension/healthcheckv2extension/internal/http/package_test.go b/extension/healthcheckv2extension/internal/http/package_test.go new file mode 100644 index 000000000000..9f1f2d2d2c7a --- /dev/null +++ b/extension/healthcheckv2extension/internal/http/package_test.go @@ -0,0 +1,14 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package http // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckv2extension/internal/http" + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/extension/healthcheckv2extension/internal/http/responders.go b/extension/healthcheckv2extension/internal/http/responders.go new file mode 100644 index 000000000000..c8b415e0bca6 --- /dev/null +++ b/extension/healthcheckv2extension/internal/http/responders.go @@ -0,0 +1,156 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package http // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckv2extension/internal/http" + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "go.opentelemetry.io/collector/component" + + "github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckv2extension/internal/common" + "github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckv2extension/internal/status" +) + +var responseCodes = map[component.Status]int{ + component.StatusNone: http.StatusServiceUnavailable, + component.StatusStarting: http.StatusServiceUnavailable, + component.StatusOK: http.StatusOK, + component.StatusRecoverableError: http.StatusOK, + component.StatusPermanentError: http.StatusOK, + component.StatusFatalError: http.StatusInternalServerError, + component.StatusStopping: http.StatusServiceUnavailable, + component.StatusStopped: http.StatusServiceUnavailable, +} + +type serializationErr struct { + ErrorMessage string `json:"error_message"` +} + +type responder interface { + respond(*status.AggregateStatus, http.ResponseWriter) error +} + +type responderFunc func(*status.AggregateStatus, http.ResponseWriter) error + +func (f responderFunc) respond(st *status.AggregateStatus, w http.ResponseWriter) error { + return f(st, w) +} + +func respondWithJSON(code int, content any, w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + + body, mErr := json.Marshal(content) + if mErr != nil { + body, _ = json.Marshal(&serializationErr{ErrorMessage: mErr.Error()}) + } + _, wErr := w.Write(body) + return wErr +} + +func defaultResponder(startTimestamp *time.Time) responderFunc { + return func(st *status.AggregateStatus, w http.ResponseWriter) error { + code := responseCodes[st.Status()] + sst := toSerializableStatus(st, &serializationOptions{ + includeStartTime: true, + startTimestamp: startTimestamp, + }) + return respondWithJSON(code, sst, w) + } +} + +func componentHealthResponder( + startTimestamp *time.Time, + config *common.ComponentHealthConfig, +) responderFunc { + healthyFunc := func(now *time.Time) func(status.Event) bool { + return func(ev status.Event) bool { + if ev.Status() == component.StatusPermanentError { + return !config.IncludePermanent + } + + if ev.Status() == component.StatusRecoverableError && config.IncludeRecoverable { + return now.Before(ev.Timestamp().Add(config.RecoveryDuration)) + } + + return ev.Status() != component.StatusFatalError + } + } + return func(st *status.AggregateStatus, w http.ResponseWriter) error { + now := time.Now() + sst := toSerializableStatus( + st, + &serializationOptions{ + includeStartTime: true, + startTimestamp: startTimestamp, + healthyFunc: healthyFunc(&now), + }, + ) + + code := responseCodes[st.Status()] + if !sst.Healthy { + code = http.StatusInternalServerError + } + + return respondWithJSON(code, sst, w) + } +} + +// Below are responders ported from the original healthcheck extension. We will +// keep them for backwards compatibility, but eventually deprecate and remove +// them. + +// legacyResponseCodes match the current response code mapping with the exception +// of FatalError, which maps to 503 instead of 500. +var legacyResponseCodes = map[component.Status]int{ + component.StatusNone: http.StatusServiceUnavailable, + component.StatusStarting: http.StatusServiceUnavailable, + component.StatusOK: http.StatusOK, + component.StatusRecoverableError: http.StatusOK, + component.StatusPermanentError: http.StatusOK, + component.StatusFatalError: http.StatusServiceUnavailable, + component.StatusStopping: http.StatusServiceUnavailable, + component.StatusStopped: http.StatusServiceUnavailable, +} + +func legacyDefaultResponder(startTimestamp *time.Time) responderFunc { + type healthCheckResponse struct { + StatusMsg string `json:"status"` + UpSince time.Time `json:"upSince"` + Uptime string `json:"uptime"` + } + + codeToMsgMap := map[int]string{ + http.StatusOK: "Server available", + http.StatusServiceUnavailable: "Server not available", + } + + return func(st *status.AggregateStatus, w http.ResponseWriter) error { + code := legacyResponseCodes[st.Status()] + resp := healthCheckResponse{ + StatusMsg: codeToMsgMap[code], + } + if code == http.StatusOK { + resp.UpSince = *startTimestamp + resp.Uptime = fmt.Sprintf("%v", time.Since(*startTimestamp)) + } + return respondWithJSON(code, resp, w) + } +} + +func legacyCustomResponder(config *ResponseBodyConfig) responderFunc { + codeToMsgMap := map[int][]byte{ + http.StatusOK: []byte(config.Healthy), + http.StatusServiceUnavailable: []byte(config.Unhealthy), + } + return func(st *status.AggregateStatus, w http.ResponseWriter) error { + code := legacyResponseCodes[st.Status()] + w.WriteHeader(code) + _, err := w.Write(codeToMsgMap[code]) + return err + } +} diff --git a/extension/healthcheckv2extension/internal/http/responders_test.go b/extension/healthcheckv2extension/internal/http/responders_test.go new file mode 100644 index 000000000000..b01624fb78ed --- /dev/null +++ b/extension/healthcheckv2extension/internal/http/responders_test.go @@ -0,0 +1,37 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package http + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// var errUnserializable = errors.New("cannot marshal JSON") +var unserializableErrString = "cannot marshal unserializable" + +type unserializable struct{} + +func (*unserializable) MarshalJSON() ([]byte, error) { + return nil, errors.New(unserializableErrString) +} + +func TestRespondWithJSON(t *testing.T) { + content := &unserializable{} + w := httptest.NewRecorder() + require.NoError(t, respondWithJSON(http.StatusOK, content, w)) + resp := w.Result() + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, resp.Header.Get("Content-Type"), "application/json") + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(body), unserializableErrString) +} diff --git a/extension/healthcheckv2extension/internal/http/serialization.go b/extension/healthcheckv2extension/internal/http/serialization.go new file mode 100644 index 000000000000..6b00933dc988 --- /dev/null +++ b/extension/healthcheckv2extension/internal/http/serialization.go @@ -0,0 +1,95 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package http // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckv2extension/internal/http" + +import ( + "time" + + "go.opentelemetry.io/collector/component" + + "github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckv2extension/internal/status" +) + +type healthyFunc func(status.Event) bool + +func (f healthyFunc) isHealthy(ev status.Event) bool { + if f != nil { + return f(ev) + } + return true +} + +type serializationOptions struct { + includeStartTime bool + startTimestamp *time.Time + healthyFunc healthyFunc +} + +type serializableStatus struct { + StartTimestamp *time.Time `json:"start_time,omitempty"` + *SerializableEvent + ComponentStatuses map[string]*serializableStatus `json:"components,omitempty"` +} + +// SerializableEvent is exported for json.Unmarshal +type SerializableEvent struct { + Healthy bool `json:"healthy"` + StatusString string `json:"status"` + Error string `json:"error,omitempty"` + Timestamp time.Time `json:"status_time"` +} + +var stringToStatusMap = map[string]component.Status{ + "StatusNone": component.StatusNone, + "StatusStarting": component.StatusStarting, + "StatusOK": component.StatusOK, + "StatusRecoverableError": component.StatusRecoverableError, + "StatusPermanentError": component.StatusPermanentError, + "StatusFatalError": component.StatusFatalError, + "StatusStopping": component.StatusStopping, + "StatusStopped": component.StatusStopped, +} + +func (ev *SerializableEvent) Status() component.Status { + if st, ok := stringToStatusMap[ev.StatusString]; ok { + return st + } + return component.StatusNone +} + +func toSerializableEvent(ev status.Event, isHealthy bool) *SerializableEvent { + se := &SerializableEvent{ + Healthy: isHealthy, + StatusString: ev.Status().String(), + Timestamp: ev.Timestamp(), + } + if ev.Err() != nil { + se.Error = ev.Err().Error() + } + return se +} + +func toSerializableStatus( + st *status.AggregateStatus, + opts *serializationOptions, +) *serializableStatus { + s := &serializableStatus{ + SerializableEvent: toSerializableEvent( + st.Event, + opts.healthyFunc.isHealthy(st.Event), + ), + ComponentStatuses: make(map[string]*serializableStatus), + } + + if opts.includeStartTime { + s.StartTimestamp = opts.startTimestamp + opts.includeStartTime = false + } + + for k, cs := range st.ComponentStatusMap { + s.ComponentStatuses[k] = toSerializableStatus(cs, opts) + } + + return s +} diff --git a/extension/healthcheckv2extension/internal/http/server.go b/extension/healthcheckv2extension/internal/http/server.go new file mode 100644 index 000000000000..1ae666379968 --- /dev/null +++ b/extension/healthcheckv2extension/internal/http/server.go @@ -0,0 +1,125 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package http // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckv2extension/internal/http" + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "sync/atomic" + "time" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/config/confighttp" + "go.opentelemetry.io/collector/confmap" + "go.opentelemetry.io/collector/extension" + "go.uber.org/zap" + + "github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckv2extension/internal/common" + "github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckv2extension/internal/status" +) + +type Server struct { + telemetry component.TelemetrySettings + httpConfig confighttp.ServerConfig + httpServer *http.Server + mux *http.ServeMux + responder responder + colconf atomic.Value + aggregator *status.Aggregator + startTimestamp time.Time + doneCh chan struct{} +} + +var _ component.Component = (*Server)(nil) +var _ extension.ConfigWatcher = (*Server)(nil) + +func NewServer( + config *Config, + legacyConfig LegacyConfig, + componentHealthConfig *common.ComponentHealthConfig, + telemetry component.TelemetrySettings, + aggregator *status.Aggregator, +) *Server { + now := time.Now() + srv := &Server{ + telemetry: telemetry, + mux: http.NewServeMux(), + aggregator: aggregator, + doneCh: make(chan struct{}), + } + + if legacyConfig.UseV2 { + srv.httpConfig = config.ServerConfig + if componentHealthConfig != nil { + srv.responder = componentHealthResponder(&now, componentHealthConfig) + } else { + srv.responder = defaultResponder(&now) + } + if config.Status.Enabled { + srv.mux.Handle(config.Status.Path, srv.statusHandler()) + } + if config.Config.Enabled { + srv.mux.Handle(config.Config.Path, srv.configHandler()) + } + } else { + srv.httpConfig = legacyConfig.ServerConfig + if legacyConfig.ResponseBody != nil { + srv.responder = legacyCustomResponder(legacyConfig.ResponseBody) + } else { + srv.responder = legacyDefaultResponder(&now) + } + srv.mux.Handle(legacyConfig.Path, srv.statusHandler()) + } + + return srv +} + +// Start implements the component.Component interface. +func (s *Server) Start(ctx context.Context, host component.Host) error { + var err error + s.startTimestamp = time.Now() + + s.httpServer, err = s.httpConfig.ToServer(ctx, host, s.telemetry, s.mux) + if err != nil { + return err + } + + ln, err := s.httpConfig.ToListener(ctx) + if err != nil { + return fmt.Errorf("failed to bind to address %s: %w", s.httpConfig.Endpoint, err) + } + + go func() { + defer close(s.doneCh) + if err = s.httpServer.Serve(ln); !errors.Is(err, http.ErrServerClosed) && err != nil { + s.telemetry.ReportStatus(component.NewPermanentErrorEvent(err)) + } + }() + + return nil +} + +// Shutdown implements the component.Component interface. +func (s *Server) Shutdown(context.Context) error { + if s.httpServer == nil { + return nil + } + s.httpServer.Close() + <-s.doneCh + return nil +} + +// NotifyConfig implements the extension.ConfigWatcher interface. +func (s *Server) NotifyConfig(_ context.Context, conf *confmap.Conf) error { + confBytes, err := json.Marshal(conf.ToStringMap()) + if err != nil { + s.telemetry.Logger.Warn("could not marshal config", zap.Error(err)) + return err + } + s.colconf.Store(confBytes) + return nil +} diff --git a/extension/healthcheckv2extension/internal/http/server_test.go b/extension/healthcheckv2extension/internal/http/server_test.go new file mode 100644 index 000000000000..7de1a08666b0 --- /dev/null +++ b/extension/healthcheckv2extension/internal/http/server_test.go @@ -0,0 +1,3146 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package http + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/component/componenttest" + "go.opentelemetry.io/collector/config/confighttp" + "go.opentelemetry.io/collector/confmap/confmaptest" + + "github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckv2extension/internal/common" + "github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckv2extension/internal/status" + "github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckv2extension/internal/testhelpers" + "github.com/open-telemetry/opentelemetry-collector-contrib/internal/common/testutil" +) + +// These are used for the legacy test assertions +const ( + expectedBodyNotReady = "{\"status\":\"Server not available\",\"upSince\":" + expectedBodyReady = "{\"status\":\"Server available\",\"upSince\":" +) + +var ( + componentStatusOK = &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + } + componentStatusPipelineMetricsStarting = map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusStarting, + }, + "processor:batch": { + healthy: true, + status: component.StatusStarting, + }, + "exporter:metrics/out": { + healthy: true, + status: component.StatusStarting, + }, + } + componentStatusPipelineMetricsOK = map[string]*componentStatusExpectation{ + "receiver:metrics/in": componentStatusOK, + "processor:batch": componentStatusOK, + "exporter:metrics/out": componentStatusOK, + } + componentStatusPipelineMetricsStopping = map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusStopping, + }, + "processor:batch": { + healthy: true, + status: component.StatusStopping, + }, + "exporter:metrics/out": { + healthy: true, + status: component.StatusStopping, + }, + } + componentStatusPipelineMetricsStopped = map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusStopped, + }, + "processor:batch": { + healthy: true, + status: component.StatusStopped, + }, + "exporter:metrics/out": { + healthy: true, + status: component.StatusStopped, + }, + } + componentStatusPipelineTracesStarting = map[string]*componentStatusExpectation{ + "receiver:traces/in": { + healthy: true, + status: component.StatusStarting, + }, + "processor:batch": { + healthy: true, + status: component.StatusStarting, + }, + "exporter:traces/out": { + healthy: true, + status: component.StatusStarting, + }, + } + componentStatusPipelineTracesOK = map[string]*componentStatusExpectation{ + "receiver:traces/in": componentStatusOK, + "processor:batch": componentStatusOK, + "exporter:traces/out": componentStatusOK, + } + componentStatusPipelineTracesStopping = map[string]*componentStatusExpectation{ + "receiver:traces/in": { + healthy: true, + status: component.StatusStopping, + }, + "processor:batch": { + healthy: true, + status: component.StatusStopping, + }, + "exporter:traces/out": { + healthy: true, + status: component.StatusStopping, + }, + } + componentStatusPipelineTracesStopped = map[string]*componentStatusExpectation{ + "receiver:traces/in": { + healthy: true, + status: component.StatusStopped, + }, + "processor:batch": { + healthy: true, + status: component.StatusStopped, + }, + "exporter:traces/out": { + healthy: true, + status: component.StatusStopped, + }, + } +) + +type componentStatusExpectation struct { + healthy bool + status component.Status + err error + nestedStatus map[string]*componentStatusExpectation +} + +type teststep struct { + step func() + queryParams string + eventually bool + expectedStatusCode int + expectedBody string + expectedComponentStatus *componentStatusExpectation +} + +func TestStatus(t *testing.T) { + var server *Server + traces := testhelpers.NewPipelineMetadata("traces") + metrics := testhelpers.NewPipelineMetadata("metrics") + + tests := []struct { + name string + config *Config + legacyConfig LegacyConfig + componentHealthConfig *common.ComponentHealthConfig + pipelines map[string]*testhelpers.PipelineMetadata + teststeps []teststep + }{ + { + name: "exclude recoverable and permanent errors", + legacyConfig: LegacyConfig{UseV2: true}, + config: &Config{ + ServerConfig: confighttp.ServerConfig{ + Endpoint: testutil.GetAvailableLocalAddress(t), + }, + Config: PathConfig{Enabled: false}, + Status: PathConfig{ + Enabled: true, + Path: "/status", + }, + }, + teststeps: []teststep{ + { + step: func() { + testhelpers.SeedAggregator(server.aggregator, + traces.InstanceIDs(), + component.StatusStarting, + ) + testhelpers.SeedAggregator(server.aggregator, + metrics.InstanceIDs(), + component.StatusStarting, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStarting, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusOK, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStarting, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStarting, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusOK, + ) + }, + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewRecoverableErrorEvent(assert.AnError), + ) + }, + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusRecoverableError, + err: assert.AnError, + }, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusRecoverableError, + err: assert.AnError, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewStatusEvent(component.StatusOK), + ) + }, + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewPermanentErrorEvent(assert.AnError), + ) + }, + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusPermanentError, + err: assert.AnError, + }, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusPermanentError, + err: assert.AnError, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopping, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopping, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + }, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopped, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopped, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + }, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + }, + }, + }, + }, + { + name: "exclude recoverable and permanent errors - verbose", + legacyConfig: LegacyConfig{UseV2: true}, + config: &Config{ + ServerConfig: confighttp.ServerConfig{ + Endpoint: testutil.GetAvailableLocalAddress(t), + }, + Config: PathConfig{Enabled: false}, + Status: PathConfig{ + Enabled: true, + Path: "/status", + }, + }, + teststeps: []teststep{ + { + step: func() { + testhelpers.SeedAggregator(server.aggregator, + traces.InstanceIDs(), + component.StatusStarting, + ) + testhelpers.SeedAggregator(server.aggregator, + metrics.InstanceIDs(), + component.StatusStarting, + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStarting, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusStarting, + nestedStatus: componentStatusPipelineTracesStarting, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusStarting, + nestedStatus: componentStatusPipelineMetricsStarting, + }, + }, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusOK, + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStarting, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusStarting, + nestedStatus: componentStatusPipelineMetricsStarting, + }, + }, + }, + }, + { + queryParams: "pipeline=traces&verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStarting, + nestedStatus: map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusStarting, + }, + "processor:batch": { + healthy: true, + status: component.StatusStarting, + }, + "exporter:metrics/out": { + healthy: true, + status: component.StatusStarting, + }, + }, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusOK, + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineMetricsOK, + }, + }, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineMetricsOK, + }, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewRecoverableErrorEvent(assert.AnError), + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusRecoverableError, + err: assert.AnError, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusRecoverableError, + nestedStatus: map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusOK, + }, + "processor:batch": { + healthy: true, + status: component.StatusOK, + }, + "exporter:metrics/out": { + healthy: true, + status: component.StatusRecoverableError, + err: assert.AnError, + }, + }, + }, + }, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusRecoverableError, + err: assert.AnError, + nestedStatus: map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusOK, + }, + "processor:batch": { + healthy: true, + status: component.StatusOK, + }, + "exporter:metrics/out": { + healthy: true, + status: component.StatusRecoverableError, + err: assert.AnError, + }, + }, + }, + }, + { + queryParams: "pipeline=traces&verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewStatusEvent(component.StatusOK), + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineMetricsOK, + }, + }, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineMetricsOK, + }, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewPermanentErrorEvent(assert.AnError), + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusPermanentError, + err: assert.AnError, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusPermanentError, + nestedStatus: map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusOK, + }, + "processor:batch": { + healthy: true, + status: component.StatusOK, + }, + "exporter:metrics/out": { + healthy: true, + status: component.StatusPermanentError, + err: assert.AnError, + }, + }, + }, + }, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusPermanentError, + err: assert.AnError, + nestedStatus: map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusOK, + }, + "processor:batch": { + healthy: true, + status: component.StatusOK, + }, + "exporter:metrics/out": { + healthy: true, + status: component.StatusPermanentError, + }, + }, + }, + }, + { + queryParams: "pipeline=traces&verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopping, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopping, + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusStopping, + nestedStatus: componentStatusPipelineTracesStopping, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusStopping, + nestedStatus: componentStatusPipelineMetricsStopping, + }, + }, + }, + }, + { + queryParams: "pipeline=traces&verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + nestedStatus: componentStatusPipelineTracesStopping, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + nestedStatus: componentStatusPipelineMetricsStopping, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopped, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopped, + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusStopped, + nestedStatus: componentStatusPipelineTracesStopped, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusStopped, + nestedStatus: componentStatusPipelineMetricsStopped, + }, + }, + }, + }, + { + queryParams: "pipeline=traces&verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + nestedStatus: componentStatusPipelineTracesStopped, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + nestedStatus: componentStatusPipelineMetricsStopped, + }, + }, + }, + }, + { + name: "include recoverable and exclude permanent errors", + legacyConfig: LegacyConfig{UseV2: true}, + config: &Config{ + ServerConfig: confighttp.ServerConfig{ + Endpoint: testutil.GetAvailableLocalAddress(t), + }, + Config: PathConfig{Enabled: false}, + Status: PathConfig{ + Enabled: true, + Path: "/status", + }, + }, + componentHealthConfig: &common.ComponentHealthConfig{ + IncludePermanent: false, + IncludeRecoverable: true, + RecoveryDuration: 2 * time.Millisecond, + }, + teststeps: []teststep{ + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStarting, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStarting, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStarting, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStarting, + }, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStarting, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusOK, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusOK, + ) + }, + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewRecoverableErrorEvent(assert.AnError), + ) + }, + eventually: true, + expectedStatusCode: http.StatusInternalServerError, + expectedComponentStatus: &componentStatusExpectation{ + healthy: false, + status: component.StatusRecoverableError, + err: assert.AnError, + }, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusInternalServerError, + expectedComponentStatus: &componentStatusExpectation{ + healthy: false, + status: component.StatusRecoverableError, + err: assert.AnError, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewStatusEvent(component.StatusOK), + ) + }, + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewPermanentErrorEvent(assert.AnError), + ) + }, + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusPermanentError, + err: assert.AnError, + }, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusPermanentError, + err: assert.AnError, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopping, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopping, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + }, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopped, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopped, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + }, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + }, + }, + }, + }, + { + name: "include recoverable and exclude permanent errors - verbose", + legacyConfig: LegacyConfig{UseV2: true}, + config: &Config{ + ServerConfig: confighttp.ServerConfig{ + Endpoint: testutil.GetAvailableLocalAddress(t), + }, + Config: PathConfig{Enabled: false}, + Status: PathConfig{ + Enabled: true, + Path: "/status", + }, + }, + componentHealthConfig: &common.ComponentHealthConfig{ + IncludePermanent: false, + IncludeRecoverable: true, + RecoveryDuration: 2 * time.Millisecond, + }, + teststeps: []teststep{ + { + step: func() { + testhelpers.SeedAggregator(server.aggregator, + traces.InstanceIDs(), + component.StatusStarting, + ) + testhelpers.SeedAggregator(server.aggregator, + metrics.InstanceIDs(), + component.StatusStarting, + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStarting, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusStarting, + nestedStatus: componentStatusPipelineTracesStarting, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusStarting, + nestedStatus: componentStatusPipelineMetricsStarting, + }, + }, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusOK, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusOK, + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineMetricsOK, + }, + }, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineMetricsOK, + }, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewRecoverableErrorEvent(assert.AnError), + ) + }, + eventually: true, + queryParams: "verbose", + expectedStatusCode: http.StatusInternalServerError, + expectedComponentStatus: &componentStatusExpectation{ + healthy: false, + status: component.StatusRecoverableError, + err: assert.AnError, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + "pipeline:metrics": { + healthy: false, + status: component.StatusRecoverableError, + nestedStatus: map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusOK, + }, + "processor:batch": { + healthy: true, + status: component.StatusOK, + }, + "exporter:metrics/out": { + healthy: false, + status: component.StatusRecoverableError, + err: assert.AnError, + }, + }, + }, + }, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusInternalServerError, + expectedComponentStatus: &componentStatusExpectation{ + healthy: false, + status: component.StatusRecoverableError, + err: assert.AnError, + nestedStatus: map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusOK, + }, + "processor:batch": { + healthy: true, + status: component.StatusOK, + }, + "exporter:metrics/out": { + healthy: false, + status: component.StatusRecoverableError, + err: assert.AnError, + }, + }, + }, + }, + { + queryParams: "pipeline=traces&verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewStatusEvent(component.StatusOK), + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineMetricsOK, + }, + }, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineMetricsOK, + }, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewPermanentErrorEvent(assert.AnError), + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusPermanentError, + err: assert.AnError, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusPermanentError, + nestedStatus: map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusOK, + }, + "processor:batch": { + healthy: true, + status: component.StatusOK, + }, + "exporter:metrics/out": { + healthy: true, + status: component.StatusPermanentError, + err: assert.AnError, + }, + }, + }, + }, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusPermanentError, + err: assert.AnError, + nestedStatus: map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusOK, + }, + "processor:batch": { + healthy: true, + status: component.StatusOK, + }, + "exporter:metrics/out": { + healthy: true, + status: component.StatusPermanentError, + }, + }, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopping, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopping, + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusStopping, + nestedStatus: componentStatusPipelineTracesStopping, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusStopping, + nestedStatus: componentStatusPipelineMetricsStopping, + }, + }, + }, + }, + { + queryParams: "pipeline=traces&verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + nestedStatus: componentStatusPipelineTracesStopping, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + nestedStatus: componentStatusPipelineMetricsStopping, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopped, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopped, + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusStopped, + nestedStatus: componentStatusPipelineTracesStopped, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusStopped, + nestedStatus: componentStatusPipelineMetricsStopped, + }, + }, + }, + }, + { + queryParams: "pipeline=traces&verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + nestedStatus: componentStatusPipelineTracesStopped, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + nestedStatus: componentStatusPipelineMetricsStopped, + }, + }, + }, + }, + { + name: "include permanent and exclude recoverable errors", + legacyConfig: LegacyConfig{UseV2: true}, + config: &Config{ + ServerConfig: confighttp.ServerConfig{ + Endpoint: testutil.GetAvailableLocalAddress(t), + }, + Config: PathConfig{Enabled: false}, + Status: PathConfig{ + Enabled: true, + Path: "/status", + }, + }, + componentHealthConfig: &common.ComponentHealthConfig{ + IncludePermanent: true, + }, + teststeps: []teststep{ + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStarting, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStarting, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStarting, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStarting, + }, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStarting, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusOK, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusOK, + ) + }, + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewRecoverableErrorEvent(assert.AnError), + ) + }, + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusRecoverableError, + err: assert.AnError, + }, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusRecoverableError, + err: assert.AnError, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewStatusEvent(component.StatusOK), + ) + }, + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewPermanentErrorEvent(assert.AnError), + ) + }, + expectedStatusCode: http.StatusInternalServerError, + expectedComponentStatus: &componentStatusExpectation{ + healthy: false, + status: component.StatusPermanentError, + err: assert.AnError, + }, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusInternalServerError, + expectedComponentStatus: &componentStatusExpectation{ + healthy: false, + status: component.StatusPermanentError, + err: assert.AnError, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopping, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopping, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + }, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopped, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopped, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + }, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + }, + }, + }, + }, + { + name: "include permanent and exclude recoverable errors - verbose", + legacyConfig: LegacyConfig{UseV2: true}, + config: &Config{ + ServerConfig: confighttp.ServerConfig{ + Endpoint: testutil.GetAvailableLocalAddress(t), + }, + Config: PathConfig{Enabled: false}, + Status: PathConfig{ + Enabled: true, + Path: "/status", + }, + }, + componentHealthConfig: &common.ComponentHealthConfig{ + IncludePermanent: true, + }, + teststeps: []teststep{ + { + step: func() { + testhelpers.SeedAggregator(server.aggregator, + traces.InstanceIDs(), + component.StatusStarting, + ) + testhelpers.SeedAggregator(server.aggregator, + metrics.InstanceIDs(), + component.StatusStarting, + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStarting, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusStarting, + nestedStatus: componentStatusPipelineTracesStarting, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusStarting, + nestedStatus: componentStatusPipelineMetricsStarting, + }, + }, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusOK, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusOK, + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineMetricsOK, + }, + }, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineMetricsOK, + }, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewRecoverableErrorEvent(assert.AnError), + ) + }, + eventually: true, + queryParams: "verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusRecoverableError, + err: assert.AnError, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusRecoverableError, + nestedStatus: map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusOK, + }, + "processor:batch": { + healthy: true, + status: component.StatusOK, + }, + "exporter:metrics/out": { + healthy: true, + status: component.StatusRecoverableError, + err: assert.AnError, + }, + }, + }, + }, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusRecoverableError, + err: assert.AnError, + nestedStatus: map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusOK, + }, + "processor:batch": { + healthy: true, + status: component.StatusOK, + }, + "exporter:metrics/out": { + healthy: true, + status: component.StatusRecoverableError, + err: assert.AnError, + }, + }, + }, + }, + { + queryParams: "pipeline=traces&verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewStatusEvent(component.StatusOK), + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineMetricsOK, + }, + }, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineMetricsOK, + }, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewPermanentErrorEvent(assert.AnError), + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusInternalServerError, + expectedComponentStatus: &componentStatusExpectation{ + healthy: false, + status: component.StatusPermanentError, + err: assert.AnError, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + "pipeline:metrics": { + healthy: false, + status: component.StatusPermanentError, + nestedStatus: map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusOK, + }, + "processor:batch": { + healthy: true, + status: component.StatusOK, + }, + "exporter:metrics/out": { + healthy: false, + status: component.StatusPermanentError, + err: assert.AnError, + }, + }, + }, + }, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusInternalServerError, + expectedComponentStatus: &componentStatusExpectation{ + healthy: false, + status: component.StatusPermanentError, + err: assert.AnError, + nestedStatus: map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusOK, + }, + "processor:batch": { + healthy: true, + status: component.StatusOK, + }, + "exporter:metrics/out": { + healthy: false, + status: component.StatusPermanentError, + }, + }, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopping, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopping, + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusStopping, + nestedStatus: componentStatusPipelineTracesStopping, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusStopping, + nestedStatus: componentStatusPipelineMetricsStopping, + }, + }, + }, + }, + { + queryParams: "pipeline=traces&verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + nestedStatus: componentStatusPipelineTracesStopping, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + nestedStatus: componentStatusPipelineMetricsStopping, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopped, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopped, + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusStopped, + nestedStatus: componentStatusPipelineTracesStopped, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusStopped, + nestedStatus: componentStatusPipelineMetricsStopped, + }, + }, + }, + }, + { + queryParams: "pipeline=traces&verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + nestedStatus: componentStatusPipelineTracesStopped, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + nestedStatus: componentStatusPipelineMetricsStopped, + }, + }, + }, + }, + { + name: "include permanent and recoverable errors", + legacyConfig: LegacyConfig{UseV2: true}, + config: &Config{ + ServerConfig: confighttp.ServerConfig{ + Endpoint: testutil.GetAvailableLocalAddress(t), + }, + Config: PathConfig{Enabled: false}, + Status: PathConfig{ + Enabled: true, + Path: "/status", + }, + }, + componentHealthConfig: &common.ComponentHealthConfig{ + IncludePermanent: true, + IncludeRecoverable: true, + RecoveryDuration: 2 * time.Millisecond, + }, + teststeps: []teststep{ + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStarting, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStarting, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStarting, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStarting, + }, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStarting, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusOK, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusOK, + ) + }, + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewRecoverableErrorEvent(assert.AnError), + ) + }, + eventually: true, + expectedStatusCode: http.StatusInternalServerError, + expectedComponentStatus: &componentStatusExpectation{ + healthy: false, + status: component.StatusRecoverableError, + err: assert.AnError, + }, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusInternalServerError, + expectedComponentStatus: &componentStatusExpectation{ + healthy: false, + status: component.StatusRecoverableError, + err: assert.AnError, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewStatusEvent(component.StatusOK), + ) + }, + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewPermanentErrorEvent(assert.AnError), + ) + }, + expectedStatusCode: http.StatusInternalServerError, + expectedComponentStatus: &componentStatusExpectation{ + healthy: false, + status: component.StatusPermanentError, + err: assert.AnError, + }, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusInternalServerError, + expectedComponentStatus: &componentStatusExpectation{ + healthy: false, + status: component.StatusPermanentError, + err: assert.AnError, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: componentStatusOK, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopping, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopping, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + }, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopped, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopped, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + }, + }, + { + queryParams: "pipeline=traces", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + }, + }, + { + queryParams: "pipeline=metrics", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + }, + }, + }, + }, + { + name: "include permanent and recoverable errors - verbose", + legacyConfig: LegacyConfig{UseV2: true}, + config: &Config{ + ServerConfig: confighttp.ServerConfig{ + Endpoint: testutil.GetAvailableLocalAddress(t), + }, + Config: PathConfig{Enabled: false}, + Status: PathConfig{ + Enabled: true, + Path: "/status", + }, + }, + componentHealthConfig: &common.ComponentHealthConfig{ + IncludePermanent: true, + IncludeRecoverable: true, + RecoveryDuration: 2 * time.Millisecond, + }, + teststeps: []teststep{ + { + step: func() { + testhelpers.SeedAggregator(server.aggregator, + traces.InstanceIDs(), + component.StatusStarting, + ) + testhelpers.SeedAggregator(server.aggregator, + metrics.InstanceIDs(), + component.StatusStarting, + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStarting, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusStarting, + nestedStatus: componentStatusPipelineTracesStarting, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusStarting, + nestedStatus: componentStatusPipelineMetricsStarting, + }, + }, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusOK, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusOK, + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineMetricsOK, + }, + }, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineMetricsOK, + }, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewRecoverableErrorEvent(assert.AnError), + ) + }, + eventually: true, + queryParams: "verbose", + expectedStatusCode: http.StatusInternalServerError, + expectedComponentStatus: &componentStatusExpectation{ + healthy: false, + status: component.StatusRecoverableError, + err: assert.AnError, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + "pipeline:metrics": { + healthy: false, + status: component.StatusRecoverableError, + nestedStatus: map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusOK, + }, + "processor:batch": { + healthy: true, + status: component.StatusOK, + }, + "exporter:metrics/out": { + healthy: false, + status: component.StatusRecoverableError, + err: assert.AnError, + }, + }, + }, + }, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusInternalServerError, + expectedComponentStatus: &componentStatusExpectation{ + healthy: false, + status: component.StatusRecoverableError, + err: assert.AnError, + nestedStatus: map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusOK, + }, + "processor:batch": { + healthy: true, + status: component.StatusOK, + }, + "exporter:metrics/out": { + healthy: false, + status: component.StatusRecoverableError, + err: assert.AnError, + }, + }, + }, + }, + { + queryParams: "pipeline=traces&verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewStatusEvent(component.StatusOK), + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineMetricsOK, + }, + }, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineMetricsOK, + }, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewPermanentErrorEvent(assert.AnError), + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusInternalServerError, + expectedComponentStatus: &componentStatusExpectation{ + healthy: false, + status: component.StatusPermanentError, + err: assert.AnError, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + "pipeline:metrics": { + healthy: false, + status: component.StatusPermanentError, + nestedStatus: map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusOK, + }, + "processor:batch": { + healthy: true, + status: component.StatusOK, + }, + "exporter:metrics/out": { + healthy: false, + status: component.StatusPermanentError, + err: assert.AnError, + }, + }, + }, + }, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusInternalServerError, + expectedComponentStatus: &componentStatusExpectation{ + healthy: false, + status: component.StatusPermanentError, + err: assert.AnError, + nestedStatus: map[string]*componentStatusExpectation{ + "receiver:metrics/in": { + healthy: true, + status: component.StatusOK, + }, + "processor:batch": { + healthy: true, + status: component.StatusOK, + }, + "exporter:metrics/out": { + healthy: false, + status: component.StatusPermanentError, + }, + }, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopping, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopping, + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusStopping, + nestedStatus: componentStatusPipelineTracesStopping, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusStopping, + nestedStatus: componentStatusPipelineMetricsStopping, + }, + }, + }, + }, + { + queryParams: "pipeline=traces&verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + nestedStatus: componentStatusPipelineTracesStopping, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopping, + nestedStatus: componentStatusPipelineMetricsStopping, + }, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopped, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopped, + ) + }, + queryParams: "verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusStopped, + nestedStatus: componentStatusPipelineTracesStopped, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusStopped, + nestedStatus: componentStatusPipelineMetricsStopped, + }, + }, + }, + }, + { + queryParams: "pipeline=traces&verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + nestedStatus: componentStatusPipelineTracesStopped, + }, + }, + { + queryParams: "pipeline=metrics&verbose", + expectedStatusCode: http.StatusServiceUnavailable, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusStopped, + nestedStatus: componentStatusPipelineMetricsStopped, + }, + }, + }, + }, + { + name: "pipeline non-existent", + legacyConfig: LegacyConfig{UseV2: true}, + config: &Config{ + ServerConfig: confighttp.ServerConfig{ + Endpoint: testutil.GetAvailableLocalAddress(t), + }, + Config: PathConfig{Enabled: false}, + Status: PathConfig{ + Enabled: true, + Path: "/status", + }, + }, + pipelines: testhelpers.NewPipelines("traces"), + teststeps: []teststep{ + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusOK, + ) + }, + queryParams: "pipeline=nonexistent", + expectedStatusCode: http.StatusNotFound, + }, + }, + }, + { + name: "verbose explicitly false", + legacyConfig: LegacyConfig{UseV2: true}, + config: &Config{ + ServerConfig: confighttp.ServerConfig{ + Endpoint: testutil.GetAvailableLocalAddress(t), + }, + Config: PathConfig{Enabled: false}, + Status: PathConfig{ + Enabled: true, + Path: "/status", + }, + }, + teststeps: []teststep{ + { + step: func() { + testhelpers.SeedAggregator(server.aggregator, + traces.InstanceIDs(), + component.StatusOK, + ) + testhelpers.SeedAggregator(server.aggregator, + metrics.InstanceIDs(), + component.StatusOK, + ) + }, + queryParams: "verbose=false", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + }, + }, + }, + }, + { + name: "verbose explicitly true", + legacyConfig: LegacyConfig{UseV2: true}, + config: &Config{ + ServerConfig: confighttp.ServerConfig{ + Endpoint: testutil.GetAvailableLocalAddress(t), + }, + Config: PathConfig{Enabled: false}, + Status: PathConfig{ + Enabled: true, + Path: "/status", + }, + }, + teststeps: []teststep{ + { + step: func() { + testhelpers.SeedAggregator(server.aggregator, + traces.InstanceIDs(), + component.StatusOK, + ) + testhelpers.SeedAggregator(server.aggregator, + metrics.InstanceIDs(), + component.StatusOK, + ) + }, + queryParams: "verbose=true", + expectedStatusCode: http.StatusOK, + expectedComponentStatus: &componentStatusExpectation{ + healthy: true, + status: component.StatusOK, + nestedStatus: map[string]*componentStatusExpectation{ + "pipeline:traces": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineTracesOK, + }, + "pipeline:metrics": { + healthy: true, + status: component.StatusOK, + nestedStatus: componentStatusPipelineMetricsOK, + }, + }, + }, + }, + }, + }, + { + name: "status disabled", + legacyConfig: LegacyConfig{UseV2: true}, + config: &Config{ + ServerConfig: confighttp.ServerConfig{ + Endpoint: testutil.GetAvailableLocalAddress(t), + }, + Config: PathConfig{Enabled: false}, + Status: PathConfig{ + Enabled: false, + }, + }, + teststeps: []teststep{ + { + expectedStatusCode: http.StatusNotFound, + }, + }, + }, + { + name: "legacy - default response", + legacyConfig: LegacyConfig{ + ServerConfig: confighttp.ServerConfig{ + Endpoint: testutil.GetAvailableLocalAddress(t), + }, + Path: "/status", + }, + teststeps: []teststep{ + { + step: func() { + testhelpers.SeedAggregator(server.aggregator, + traces.InstanceIDs(), + component.StatusStarting, + ) + testhelpers.SeedAggregator(server.aggregator, + metrics.InstanceIDs(), + component.StatusStarting, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedBody: expectedBodyNotReady, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusOK, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedBody: expectedBodyNotReady, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusOK, + ) + }, + expectedStatusCode: http.StatusOK, + expectedBody: expectedBodyReady, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewRecoverableErrorEvent(assert.AnError), + ) + }, + expectedStatusCode: http.StatusOK, + expectedBody: expectedBodyReady, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewStatusEvent(component.StatusOK), + ) + }, + expectedStatusCode: http.StatusOK, + expectedBody: expectedBodyReady, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewPermanentErrorEvent(assert.AnError), + ) + }, + expectedStatusCode: http.StatusOK, + expectedBody: expectedBodyReady, + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewFatalErrorEvent(assert.AnError), + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedBody: expectedBodyNotReady, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopping, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopping, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedBody: expectedBodyNotReady, + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopped, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopped, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedBody: expectedBodyNotReady, + }, + }, + }, + { + name: "legacy - custom response", + legacyConfig: LegacyConfig{ + ServerConfig: confighttp.ServerConfig{ + Endpoint: testutil.GetAvailableLocalAddress(t), + }, + Path: "/status", + ResponseBody: &ResponseBodyConfig{Healthy: "ALL OK", Unhealthy: "NOT OK"}, + }, + teststeps: []teststep{ + { + step: func() { + testhelpers.SeedAggregator(server.aggregator, + traces.InstanceIDs(), + component.StatusStarting, + ) + testhelpers.SeedAggregator(server.aggregator, + metrics.InstanceIDs(), + component.StatusStarting, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedBody: "NOT OK", + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusOK, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedBody: "NOT OK", + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusOK, + ) + }, + expectedStatusCode: http.StatusOK, + expectedBody: "ALL OK", + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewRecoverableErrorEvent(assert.AnError), + ) + }, + expectedStatusCode: http.StatusOK, + expectedBody: "ALL OK", + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewStatusEvent(component.StatusOK), + ) + }, + expectedStatusCode: http.StatusOK, + expectedBody: "ALL OK", + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewPermanentErrorEvent(assert.AnError), + ) + }, + expectedStatusCode: http.StatusOK, + expectedBody: "ALL OK", + }, + { + step: func() { + server.aggregator.RecordStatus( + metrics.ExporterID, + component.NewFatalErrorEvent(assert.AnError), + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedBody: "NOT OK", + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopping, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopping, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedBody: "NOT OK", + }, + { + step: func() { + testhelpers.SeedAggregator( + server.aggregator, + traces.InstanceIDs(), + component.StatusStopped, + ) + testhelpers.SeedAggregator( + server.aggregator, + metrics.InstanceIDs(), + component.StatusStopped, + ) + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedBody: "NOT OK", + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server = NewServer( + tc.config, + tc.legacyConfig, + tc.componentHealthConfig, + componenttest.NewNopTelemetrySettings(), + status.NewAggregator(testhelpers.ErrPriority(tc.componentHealthConfig)), + ) + + require.NoError(t, server.Start(context.Background(), componenttest.NewNopHost())) + defer func() { require.NoError(t, server.Shutdown(context.Background())) }() + + var url string + if tc.legacyConfig.UseV2 { + url = fmt.Sprintf("http://%s%s", tc.config.Endpoint, tc.config.Status.Path) + } else { + url = fmt.Sprintf("http://%s%s", tc.legacyConfig.Endpoint, tc.legacyConfig.Path) + } + + client := &http.Client{} + + for _, ts := range tc.teststeps { + if ts.step != nil { + ts.step() + } + + stepURL := url + if ts.queryParams != "" { + stepURL = fmt.Sprintf("%s?%s", stepURL, ts.queryParams) + } + + var err error + var resp *http.Response + + if ts.eventually { + assert.Eventually(t, func() bool { + resp, err = client.Get(stepURL) + require.NoError(t, err) + return ts.expectedStatusCode == resp.StatusCode + }, time.Second, 10*time.Millisecond) + } else { + resp, err = client.Get(stepURL) + require.NoError(t, err) + assert.Equal(t, ts.expectedStatusCode, resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.True(t, strings.Contains(string(body), ts.expectedBody)) + + if ts.expectedComponentStatus != nil { + st := &serializableStatus{} + require.NoError(t, json.Unmarshal(body, st)) + if strings.Contains(ts.queryParams, "verbose") && !strings.Contains(ts.queryParams, "verbose=false") { + assertStatusDetailed(t, ts.expectedComponentStatus, st) + continue + } + assertStatusSimple(t, ts.expectedComponentStatus, st) + } + } + }) + } +} + +func assertStatusDetailed( + t *testing.T, + expected *componentStatusExpectation, + actual *serializableStatus, +) { + assert.Equal(t, expected.healthy, actual.Healthy) + assert.Equal(t, expected.status, actual.Status(), + "want: %s, got: %s", expected.status, actual.Status()) + if expected.err != nil { + assert.Equal(t, expected.err.Error(), actual.Error) + } + assertNestedStatus(t, expected.nestedStatus, actual.ComponentStatuses) +} + +func assertNestedStatus( + t *testing.T, + expected map[string]*componentStatusExpectation, + actual map[string]*serializableStatus, +) { + for k, expectation := range expected { + st, ok := actual[k] + require.True(t, ok, "status for key: %s not found", k) + assert.Equal(t, expectation.healthy, st.Healthy) + assert.Equal(t, expectation.status, st.Status(), + "want: %s, got: %s", expectation.status, st.Status()) + if expectation.err != nil { + assert.Equal(t, expectation.err.Error(), st.Error) + } + assertNestedStatus(t, expectation.nestedStatus, st.ComponentStatuses) + } +} + +func assertStatusSimple( + t *testing.T, + expected *componentStatusExpectation, + actual *serializableStatus, +) { + assert.Equal(t, expected.status, actual.Status()) + assert.Equal(t, expected.healthy, actual.Healthy) + if expected.err != nil { + assert.Equal(t, expected.err.Error(), actual.Error) + } + assert.Nil(t, actual.ComponentStatuses) +} + +func TestConfig(t *testing.T) { + var server *Server + confMap, err := confmaptest.LoadConf(filepath.Join("testdata", "config.yaml")) + require.NoError(t, err) + confJSON, err := os.ReadFile(filepath.Clean(filepath.Join("testdata", "config.json"))) + require.NoError(t, err) + + for _, tc := range []struct { + name string + config *Config + setup func() + expectedStatusCode int + expectedBody []byte + }{ + { + name: "config not notified", + config: &Config{ + ServerConfig: confighttp.ServerConfig{ + Endpoint: testutil.GetAvailableLocalAddress(t), + }, + Config: PathConfig{ + Enabled: true, + Path: "/config", + }, + Status: PathConfig{ + Enabled: false, + }, + }, + expectedStatusCode: http.StatusServiceUnavailable, + expectedBody: []byte{}, + }, + { + name: "config notified", + config: &Config{ + ServerConfig: confighttp.ServerConfig{ + Endpoint: testutil.GetAvailableLocalAddress(t), + }, + Config: PathConfig{ + Enabled: true, + Path: "/config", + }, + Status: PathConfig{ + Enabled: false, + }, + }, + setup: func() { + require.NoError(t, server.NotifyConfig(context.Background(), confMap)) + }, + expectedStatusCode: http.StatusOK, + expectedBody: confJSON, + }, + { + name: "config disabled", + config: &Config{ + ServerConfig: confighttp.ServerConfig{ + Endpoint: testutil.GetAvailableLocalAddress(t), + }, + Config: PathConfig{ + Enabled: false, + }, + Status: PathConfig{ + Enabled: false, + }, + }, + expectedStatusCode: http.StatusNotFound, + expectedBody: []byte("404 page not found\n"), + }, + } { + t.Run(tc.name, func(t *testing.T) { + server = NewServer( + tc.config, + LegacyConfig{UseV2: true}, + &common.ComponentHealthConfig{}, + componenttest.NewNopTelemetrySettings(), + status.NewAggregator(status.PriorityPermanent), + ) + + require.NoError(t, server.Start(context.Background(), componenttest.NewNopHost())) + defer func() { require.NoError(t, server.Shutdown(context.Background())) }() + + client := &http.Client{} + url := fmt.Sprintf("http://%s%s", tc.config.Endpoint, tc.config.Config.Path) + + if tc.setup != nil { + tc.setup() + } + + resp, err := client.Get(url) + require.NoError(t, err) + assert.Equal(t, tc.expectedStatusCode, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, tc.expectedBody, body) + }) + } + +} diff --git a/extension/healthcheckv2extension/internal/http/testdata/config.json b/extension/healthcheckv2extension/internal/http/testdata/config.json new file mode 100644 index 000000000000..55dc317f7c4d --- /dev/null +++ b/extension/healthcheckv2extension/internal/http/testdata/config.json @@ -0,0 +1 @@ +{"exporters":{"nop":null,"nop/myexporter":null},"extensions":{"nop":null,"nop/myextension":null},"processors":{"nop":null,"nop/myprocessor":null},"receivers":{"nop":null,"nop/myreceiver":null},"service":{"extensions":["nop"],"pipelines":{"traces":{"exporters":["nop"],"processors":["nop"],"receivers":["nop"]}}}} \ No newline at end of file diff --git a/extension/healthcheckv2extension/internal/http/testdata/config.yaml b/extension/healthcheckv2extension/internal/http/testdata/config.yaml new file mode 100644 index 000000000000..38227d7a68bc --- /dev/null +++ b/extension/healthcheckv2extension/internal/http/testdata/config.yaml @@ -0,0 +1,23 @@ +receivers: + nop: + nop/myreceiver: + +processors: + nop: + nop/myprocessor: + +exporters: + nop: + nop/myexporter: + +extensions: + nop: + nop/myextension: + +service: + extensions: [nop] + pipelines: + traces: + receivers: [nop] + processors: [nop] + exporters: [nop]