From 9b515fb83b3f010c4c37f3135caf535e391fb3a3 Mon Sep 17 00:00:00 2001 From: Nassim Boutekedjiret Date: Tue, 4 Feb 2025 23:39:33 +0100 Subject: [PATCH] [exporter/bmchelix] Second PR of New component: BMC Helix Exporter (#37350) * Implemented core functionality in `exporter.go` to handle metric transformation and payload dispatch to BMC Helix. * Added `metrics_client.go` with HTTP client configuration. * Added `metrics_producer.go` to produce BMC Helix-compatible payloads. * Added unit tests. #### Description This pull request introduces the BMC Helix Exporter, which is responsible for exporting metrics to BMC Helix Operations Management. The most important changes include the implementation of the exporter, configuration adjustments, and the addition of unit tests. ##### Implementation of BMC Helix Exporter: * [`exporter/bmchelixexporter/exporter.go`](diffhunk://#diff-4a430330ea5f9e90bc37821ca52351bc66219d4da8a1da11299b049cd4864214R1-R84): Introduced `BmcHelixExporter` class with methods to start the exporter and push metrics to BMC Helix. * Developed `metrics_producer.go` to build the BMC Helix Operations Management expected metric payloads. * [`exporter/bmchelixexporter/metrics_client.go`](diffhunk://#diff-9a8d71b75da3d5cc679440769fae03014ae601d122e65e5797ea9866cb9cf341R1-R93): Added `MetricsClient` class responsible for sending metrics payloads to BMC Helix. ##### Configuration Adjustments: * [`exporter/bmchelixexporter/config.go`](diffhunk://#diff-4b576c9fd2cd87cecaf1fd1182f88283e38a6e2f4c6c28549f98f22ad60949dcL8-L17): Replaced `Endpoint` and `Timeout` fields with `confighttp.ClientConfig` to handle HTTP client configuration. * [`exporter/bmchelixexporter/factory.go`](diffhunk://#diff-f69eea4d007085533aa8bb35cc91f6c41f1783a87998eaa3ddefd1730ea2d0fbR30-R54): Updated `createDefaultConfig` to use `confighttp.NewDefaultClientConfig` and adjusted the `createMetricsExporter` function to initialize the exporter properly. ##### Unit Tests: * [`exporter/bmchelixexporter/config_test.go`](diffhunk://#diff-b79eedb9dda64716aecd4902019fc29dc3ade40d26b5b32081d60d5c43d6fd4bL34-L45): Modified tests to accommodate changes in configuration structure and added helper function `createDefaultClientConfig`. * [`exporter/bmchelixexporter/exporter_test.go`](diffhunk://#diff-ed008fa0f26264e77c8516b3334f98fdc7f9bbac4fd7bc09719033c57331ad5fR1-R28): Added unit tests for `newBmcHelixExporter` function to ensure proper initialization. * `exporter/bmchelixexporter/metrics_producer.go`: Added unit tests. * `exporter/bmchelixexporter/metrics_client.go`: Added unit tests. ##### Changelog Entry: * [`.chloggen/bmchelixexporter-metrics-implementation.yaml`](diffhunk://#diff-e86aa08891f9688ad1995e82d398c481bedbbdea3903847c853585869c2feb65R1-R27): Added a new changelog entry for the BMC Helix Exporter metrics implementation. #### Link to tracking issue Fixes #36773 --- ...chelixexporter-metrics-implementation.yaml | 27 ++ exporter/bmchelixexporter/config.go | 10 +- exporter/bmchelixexporter/config_test.go | 38 +-- exporter/bmchelixexporter/exporter_metrics.go | 76 +++++ .../bmchelixexporter/exporter_metrics_test.go | 28 ++ exporter/bmchelixexporter/factory.go | 24 +- exporter/bmchelixexporter/go.mod | 19 +- exporter/bmchelixexporter/go.sum | 36 ++- .../operationsmanagement/metrics_client.go | 95 +++++++ .../metrics_client_test.go | 217 +++++++++++++++ .../operationsmanagement/metrics_producer.go | 262 ++++++++++++++++++ .../metrics_producer_test.go | 120 ++++++++ 12 files changed, 919 insertions(+), 33 deletions(-) create mode 100644 .chloggen/bmchelixexporter-metrics-implementation.yaml create mode 100644 exporter/bmchelixexporter/exporter_metrics.go create mode 100644 exporter/bmchelixexporter/exporter_metrics_test.go create mode 100644 exporter/bmchelixexporter/internal/operationsmanagement/metrics_client.go create mode 100644 exporter/bmchelixexporter/internal/operationsmanagement/metrics_client_test.go create mode 100644 exporter/bmchelixexporter/internal/operationsmanagement/metrics_producer.go create mode 100644 exporter/bmchelixexporter/internal/operationsmanagement/metrics_producer_test.go diff --git a/.chloggen/bmchelixexporter-metrics-implementation.yaml b/.chloggen/bmchelixexporter-metrics-implementation.yaml new file mode 100644 index 000000000000..d97a8ec002ff --- /dev/null +++ b/.chloggen/bmchelixexporter-metrics-implementation.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: new_component + +# The name of the component, or a single word describing the area of concern, (e.g. filelogreceiver) +component: bmchelixexporter + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: metrics implementation + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [36773] + +# (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: [user] \ No newline at end of file diff --git a/exporter/bmchelixexporter/config.go b/exporter/bmchelixexporter/config.go index 35590af2d880..c0c4eb7ebf34 100644 --- a/exporter/bmchelixexporter/config.go +++ b/exporter/bmchelixexporter/config.go @@ -5,17 +5,17 @@ package bmchelixexporter // import "github.com/open-telemetry/opentelemetry-coll import ( "errors" - "time" + "go.opentelemetry.io/collector/config/confighttp" + "go.opentelemetry.io/collector/config/configopaque" "go.opentelemetry.io/collector/config/configretry" ) // Config struct is used to store the configuration of the exporter type Config struct { - Endpoint string `mapstructure:"endpoint"` - APIKey string `mapstructure:"api_key"` - Timeout time.Duration `mapstructure:"timeout"` - RetryConfig configretry.BackOffConfig `mapstructure:"retry_on_failure"` + confighttp.ClientConfig `mapstructure:",squash"` + APIKey configopaque.String `mapstructure:"api_key"` + RetryConfig configretry.BackOffConfig `mapstructure:"retry_on_failure"` } // validate the configuration diff --git a/exporter/bmchelixexporter/config_test.go b/exporter/bmchelixexporter/config_test.go index 140ce5526447..c6246adf7a46 100644 --- a/exporter/bmchelixexporter/config_test.go +++ b/exporter/bmchelixexporter/config_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/config/confighttp" "go.opentelemetry.io/collector/config/configretry" "go.opentelemetry.io/collector/confmap/confmaptest" @@ -31,18 +32,16 @@ func TestLoadConfig(t *testing.T) { { id: component.NewIDWithName(metadata.Type, "helix1"), expected: &Config{ - Endpoint: "https://helix1:8080", - APIKey: "api_key", - Timeout: 10 * time.Second, - RetryConfig: configretry.NewDefaultBackOffConfig(), + ClientConfig: createDefaultClientConfig("https://helix1:8080", 10*time.Second), + APIKey: "api_key", + RetryConfig: configretry.NewDefaultBackOffConfig(), }, }, { id: component.NewIDWithName(metadata.Type, "helix2"), expected: &Config{ - Endpoint: "https://helix2:8080", - APIKey: "api_key", - Timeout: 20 * time.Second, + ClientConfig: createDefaultClientConfig("https://helix2:8080", 20*time.Second), + APIKey: "api_key", RetryConfig: configretry.BackOffConfig{ Enabled: true, InitialInterval: 5 * time.Second, @@ -79,9 +78,8 @@ func TestValidateConfig(t *testing.T) { { name: "valid_config", config: &Config{ - Endpoint: "https://helix:8080", - APIKey: "api_key", - Timeout: 10 * time.Second, + ClientConfig: createDefaultClientConfig("https://helix:8080", 10*time.Second), + APIKey: "api_key", }, }, { @@ -94,25 +92,23 @@ func TestValidateConfig(t *testing.T) { { name: "invalid_config2", config: &Config{ - Endpoint: "https://helix:8080", + ClientConfig: createDefaultClientConfig("https://helix:8080", 10*time.Second), }, err: "api key is required", }, { name: "invalid_config3", config: &Config{ - Endpoint: "https://helix:8080", - APIKey: "api_key", - Timeout: -1, + ClientConfig: createDefaultClientConfig("https://helix:8080", -1), + APIKey: "api_key", }, err: "timeout must be a positive integer", }, { name: "invalid_config4", config: &Config{ - Endpoint: "https://helix:8080", - APIKey: "api_key", - Timeout: 0, + ClientConfig: createDefaultClientConfig("https://helix:8080", 0), + APIKey: "api_key", }, err: "timeout must be a positive integer", }, @@ -130,3 +126,11 @@ func TestValidateConfig(t *testing.T) { }) } } + +// createDefaultClientConfig creates a default client config for testing +func createDefaultClientConfig(endpoint string, timeout time.Duration) confighttp.ClientConfig { + cfg := confighttp.NewDefaultClientConfig() + cfg.Endpoint = endpoint + cfg.Timeout = timeout + return cfg +} diff --git a/exporter/bmchelixexporter/exporter_metrics.go b/exporter/bmchelixexporter/exporter_metrics.go new file mode 100644 index 000000000000..9751c293a112 --- /dev/null +++ b/exporter/bmchelixexporter/exporter_metrics.go @@ -0,0 +1,76 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package bmchelixexporter // import "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/bmchelixexporter" + +import ( + "context" + "errors" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/exporter" + "go.opentelemetry.io/collector/pdata/pmetric" + "go.uber.org/zap" + + om "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/bmchelixexporter/internal/operationsmanagement" +) + +// metricsExporter is responsible for exporting metrics to BMC Helix +type metricsExporter struct { + config *Config + logger *zap.Logger + version string + telemetrySettings component.TelemetrySettings + producer *om.MetricsProducer + client *om.MetricsClient +} + +// newMetricsExporter instantiates a new metrics exporter for BMC Helix +func newMetricsExporter(config *Config, createSettings exporter.Settings) (*metricsExporter, error) { + if config == nil { + return nil, errors.New("nil config") + } + + return &metricsExporter{ + config: config, + version: createSettings.BuildInfo.Version, + logger: createSettings.Logger, + telemetrySettings: createSettings.TelemetrySettings, + }, nil +} + +// pushMetrics is invoked by the OpenTelemetry Collector to push metrics to BMC Helix +func (me *metricsExporter) pushMetrics(ctx context.Context, md pmetric.Metrics) error { + helixMetrics, err := me.producer.ProduceHelixPayload(md) + if err != nil { + me.logger.Error("Failed to build BMC Helix Metrics payload", zap.Error(err)) + return err + } + + err = me.client.SendHelixPayload(ctx, helixMetrics) + if err != nil { + me.logger.Error("Failed to send BMC Helix Metrics payload", zap.Error(err)) + return err + } + + return nil +} + +// start is invoked during service start +func (me *metricsExporter) start(ctx context.Context, host component.Host) error { + me.logger.Info("Starting BMC Helix Metrics Exporter") + + // Initialize and store the MetricsProducer + me.producer = om.NewMetricsProducer(me.logger) + + // Initialize and store the MetricsClient + client, err := om.NewMetricsClient(ctx, me.config.ClientConfig, me.config.APIKey, host, me.telemetrySettings, me.logger) + if err != nil { + me.logger.Error("Failed to create MetricsClient", zap.Error(err)) + return err + } + me.client = client + + me.logger.Info("Initialized BMC Helix Metrics Exporter") + return nil +} diff --git a/exporter/bmchelixexporter/exporter_metrics_test.go b/exporter/bmchelixexporter/exporter_metrics_test.go new file mode 100644 index 000000000000..c61f60a587dc --- /dev/null +++ b/exporter/bmchelixexporter/exporter_metrics_test.go @@ -0,0 +1,28 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package bmchelixexporter + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/collector/exporter/exportertest" +) + +func TestNewMetricsExporterWithNilConfig(t *testing.T) { + t.Parallel() + + exp, err := newMetricsExporter(nil, exportertest.NewNopSettings()) + assert.Nil(t, exp) + assert.Error(t, err) +} + +func TestNewMetricsExporterWithDefaultConfig(t *testing.T) { + t.Parallel() + + cfg := createDefaultConfig().(*Config) + exp, err := newMetricsExporter(cfg, exportertest.NewNopSettings()) + assert.NotNil(t, exp) + assert.NoError(t, err) +} diff --git a/exporter/bmchelixexporter/factory.go b/exporter/bmchelixexporter/factory.go index 5c2dcf4603e2..afd77e7c6f02 100644 --- a/exporter/bmchelixexporter/factory.go +++ b/exporter/bmchelixexporter/factory.go @@ -8,10 +8,10 @@ import ( "time" "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/config/confighttp" "go.opentelemetry.io/collector/config/configretry" "go.opentelemetry.io/collector/exporter" "go.opentelemetry.io/collector/exporter/exporterhelper" - "go.opentelemetry.io/collector/pdata/pmetric" "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/bmchelixexporter/internal/metadata" ) @@ -27,20 +27,30 @@ func NewFactory() exporter.Factory { // creates the default configuration for the BMC Helix exporter func createDefaultConfig() component.Config { + httpClientConfig := confighttp.NewDefaultClientConfig() + httpClientConfig.Timeout = 10 * time.Second + return &Config{ - Timeout: 10 * time.Second, - RetryConfig: configretry.NewDefaultBackOffConfig(), + ClientConfig: httpClientConfig, + RetryConfig: configretry.NewDefaultBackOffConfig(), } } // creates an exporter.Metrics that records observability metrics for BMC Helix func createMetricsExporter(ctx context.Context, set exporter.Settings, cfg component.Config) (exporter.Metrics, error) { + config := cfg.(*Config) + exporter, err := newMetricsExporter(config, set) + if err != nil { + return nil, err + } + return exporterhelper.NewMetrics( ctx, set, - cfg, - func(_ context.Context, _ pmetric.Metrics) error { - return nil - }, + config, + exporter.pushMetrics, + exporterhelper.WithTimeout(exporterhelper.TimeoutConfig{Timeout: 0}), + exporterhelper.WithRetry(config.RetryConfig), + exporterhelper.WithStart(exporter.start), ) } diff --git a/exporter/bmchelixexporter/go.mod b/exporter/bmchelixexporter/go.mod index 049b3a79c9e6..e813f305f5f0 100644 --- a/exporter/bmchelixexporter/go.mod +++ b/exporter/bmchelixexporter/go.mod @@ -6,24 +6,32 @@ require ( github.com/stretchr/testify v1.10.0 go.opentelemetry.io/collector/component v0.119.0 go.opentelemetry.io/collector/component/componenttest v0.119.0 + go.opentelemetry.io/collector/config/confighttp v0.119.0 + go.opentelemetry.io/collector/config/configopaque v1.25.0 go.opentelemetry.io/collector/config/configretry v1.25.0 go.opentelemetry.io/collector/confmap v1.25.0 go.opentelemetry.io/collector/exporter v0.119.0 go.opentelemetry.io/collector/exporter/exportertest v0.119.0 go.opentelemetry.io/collector/pdata v1.25.0 + go.opentelemetry.io/collector/semconv v0.119.0 go.uber.org/goleak v1.3.0 + go.uber.org/zap v1.27.0 ) require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/snappy v0.0.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.11 // indirect github.com/knadh/koanf/maps v0.1.1 // indirect github.com/knadh/koanf/providers/confmap v0.1.0 // indirect github.com/knadh/koanf/v2 v2.1.2 // indirect @@ -31,15 +39,22 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rs/cors v1.11.1 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/collector/client v1.25.0 // indirect + go.opentelemetry.io/collector/config/configauth v0.119.0 // indirect + go.opentelemetry.io/collector/config/configcompression v1.25.0 // indirect go.opentelemetry.io/collector/config/configtelemetry v0.119.0 // indirect + go.opentelemetry.io/collector/config/configtls v1.25.0 // indirect go.opentelemetry.io/collector/consumer v1.25.0 // indirect go.opentelemetry.io/collector/consumer/consumererror v0.119.0 // indirect go.opentelemetry.io/collector/consumer/consumertest v0.119.0 // indirect go.opentelemetry.io/collector/consumer/xconsumer v0.119.0 // indirect go.opentelemetry.io/collector/exporter/xexporter v0.119.0 // indirect go.opentelemetry.io/collector/extension v0.119.0 // indirect + go.opentelemetry.io/collector/extension/auth v0.119.0 // indirect go.opentelemetry.io/collector/extension/xextension v0.119.0 // indirect go.opentelemetry.io/collector/featuregate v1.25.0 // indirect go.opentelemetry.io/collector/pdata/pprofile v0.119.0 // indirect @@ -47,14 +62,14 @@ require ( go.opentelemetry.io/collector/receiver v0.119.0 // indirect go.opentelemetry.io/collector/receiver/receivertest v0.119.0 // indirect go.opentelemetry.io/collector/receiver/xreceiver v0.119.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect go.opentelemetry.io/otel v1.34.0 // indirect go.opentelemetry.io/otel/metric v1.34.0 // indirect go.opentelemetry.io/otel/sdk v1.34.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.34.0 // indirect go.opentelemetry.io/otel/trace v1.34.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect - golang.org/x/net v0.33.0 // indirect + golang.org/x/net v0.34.0 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect diff --git a/exporter/bmchelixexporter/go.sum b/exporter/bmchelixexporter/go.sum index e91c47ee0cec..de33a702dfe3 100644 --- a/exporter/bmchelixexporter/go.sum +++ b/exporter/bmchelixexporter/go.sum @@ -3,6 +3,10 @@ github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -14,6 +18,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -25,6 +31,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/providers/confmap v0.1.0 h1:gOkxhHkemwG4LezxxN8DMOFopOPghxRVp7JbIvdvqzU= @@ -44,10 +52,14 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= @@ -56,14 +68,26 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/collector/client v1.25.0 h1:7IS+b3Xm2ymgmQj9UbnZmVF4jIw6F7tQjJP7lFc+GoM= +go.opentelemetry.io/collector/client v1.25.0/go.mod h1:IPyOnO7K0ztuZOV1i+WXShvq4tpbLp45tTDdIDvlZvM= go.opentelemetry.io/collector/component v0.119.0 h1:ZVp9myF1Bc4BLa1V4C15Jy/VpqKPPhvbxpe9pP1mPMc= go.opentelemetry.io/collector/component v0.119.0/go.mod h1:wtuWxFl+Ky9E/5+t2FwHoLyADDiBFFDdx8fN3fEs0n8= go.opentelemetry.io/collector/component/componenttest v0.119.0 h1:nVlBmKSu56zO/qCcNgDYCQsRoWAL+NPkrkIPAbapdQM= go.opentelemetry.io/collector/component/componenttest v0.119.0/go.mod h1:H6KVzLkNhB/deEijLcq91Kjgs9Oshx2ZsFAwaMcuTLs= +go.opentelemetry.io/collector/config/configauth v0.119.0 h1:w/Ln2l6TSgadtRLEZ7mlmOsW/6Q4ITIrjwxR7Tbnfzg= +go.opentelemetry.io/collector/config/configauth v0.119.0/go.mod h1:B3DFUBTSGdwAjxbWtY/tQ+03QwousCMLM9s26+Kb9Xw= +go.opentelemetry.io/collector/config/configcompression v1.25.0 h1:iYeeYiKbTQu9lqWDpszeAc5gRkWBImDrBVe7u5gnoqw= +go.opentelemetry.io/collector/config/configcompression v1.25.0/go.mod h1:LvYG00tbPTv0NOLoZN0wXq1F5thcxvukO8INq7xyfWU= +go.opentelemetry.io/collector/config/confighttp v0.119.0 h1:slt4Msm2D4qdu2Nvy2E+ccgrAS0T64zl6eTuWiiCxGg= +go.opentelemetry.io/collector/config/confighttp v0.119.0/go.mod h1:Tnfo1UP1OZPVfvYriaP187aS3FHfwVXNLjBZ799AUFk= +go.opentelemetry.io/collector/config/configopaque v1.25.0 h1:raFi+CC8Sn4KzKCPhtnnrnkDQ0eFzJCN8xJpQh9d1sU= +go.opentelemetry.io/collector/config/configopaque v1.25.0/go.mod h1:sW0t0iI/VfRL9VYX7Ik6XzVgPcR+Y5kejTLsYcMyDWs= go.opentelemetry.io/collector/config/configretry v1.25.0 h1:PelzRkTJ9zGxwdJha7pPtvR91GrgL/OzkY/MwyXYRUE= go.opentelemetry.io/collector/config/configretry v1.25.0/go.mod h1:cleBc9I0DIWpTiiHfu9v83FUaCTqcPXmebpLxjEIqro= go.opentelemetry.io/collector/config/configtelemetry v0.119.0 h1:gAgMUEVXZKgpASxOrhS55DyA/aYatq0U6gitZI8MLXw= go.opentelemetry.io/collector/config/configtelemetry v0.119.0/go.mod h1:SlBEwQg0qly75rXZ6W1Ig8jN25KBVBkFIIAUI1GiAAE= +go.opentelemetry.io/collector/config/configtls v1.25.0 h1:x915Us8mhYWGB025LBMH8LT9ZPdvg2WKAyCQ7IDUSfw= +go.opentelemetry.io/collector/config/configtls v1.25.0/go.mod h1:jE4WbJE12AltJ3BZU1R0GnYI8D14bTqbTq4yuaTHdms= go.opentelemetry.io/collector/confmap v1.25.0 h1:dLqd6hF4JqcDHl5GWWhc2jXsHs3hkq3KPvU/2Nw5aN4= go.opentelemetry.io/collector/confmap v1.25.0/go.mod h1:Rrhs+MWoaP6AswZp+ReQ2VO9dfOfcUjdjiSHBsG+nec= go.opentelemetry.io/collector/consumer v1.25.0 h1:qCJa7Hh7lY3vYWgwcEgTGSjjITLCn+BSsya8LxjpoPY= @@ -82,6 +106,10 @@ go.opentelemetry.io/collector/exporter/xexporter v0.119.0 h1:bCUFRa/of+iPrJoXyzJ go.opentelemetry.io/collector/exporter/xexporter v0.119.0/go.mod h1:naV2XoiJv8bvOt7Vs9h6aDWmJnuD1SRnDqkIFRINYlI= go.opentelemetry.io/collector/extension v0.119.0 h1:Itkt3jqYLjkhoX4kWhICuhXQEQz332W7UL6DpmaNHMc= go.opentelemetry.io/collector/extension v0.119.0/go.mod h1:yMpvs58Z9F3UpSoE4w/1q/EEKlLFZBOQ2muzzikRvO8= +go.opentelemetry.io/collector/extension/auth v0.119.0 h1:URPkjeo3aKmlYGgeFCZK6kLK+D1XGfDUGSAwFaHn+QQ= +go.opentelemetry.io/collector/extension/auth v0.119.0/go.mod h1:8mGcTLfgmf2QNrdumP7g7nnNtyrpHiPRZect1tdXYJQ= +go.opentelemetry.io/collector/extension/auth/authtest v0.119.0 h1:J3oqlamxI+1BvRSxFIOkjMZl2E534YM6y3O8seM0yzE= +go.opentelemetry.io/collector/extension/auth/authtest v0.119.0/go.mod h1:EpUkiFC9siKB/PXeTk9KFutJhZrd6I/AHBM5en4yXlM= go.opentelemetry.io/collector/extension/extensiontest v0.119.0 h1:sAdIBRJ6Df7jdkHWY/pSEYTersxURkUz9pENKl73n6s= go.opentelemetry.io/collector/extension/extensiontest v0.119.0/go.mod h1:XQbUTXneJ//xt58eu5ofHhzWQcQ24GRTbBMWHCEsipA= go.opentelemetry.io/collector/extension/xextension v0.119.0 h1:uSUvha4yxk5jWevhepsQ56QSAOkk3Z4M0vcPEJeZ6UU= @@ -102,6 +130,10 @@ go.opentelemetry.io/collector/receiver/receivertest v0.119.0 h1:thZkyftPCNit/m2b go.opentelemetry.io/collector/receiver/receivertest v0.119.0/go.mod h1:DZM70vofnquGkQiTfT5ZSFZlohxANl9XOrVq9h5IKnc= go.opentelemetry.io/collector/receiver/xreceiver v0.119.0 h1:ZcTO+h+r9TyR1XgMhA7FTSTV9RF+z/IDPrcRIg1l56U= go.opentelemetry.io/collector/receiver/xreceiver v0.119.0/go.mod h1:AkoWhnYFMygK7Tlzez398ti20NqydX8wxPVWU86+baE= +go.opentelemetry.io/collector/semconv v0.119.0 h1:xo+V3a7hnK0I6fxAWCXT8BIT1PCBYd4emolhoKSDUlI= +go.opentelemetry.io/collector/semconv v0.119.0/go.mod h1:N6XE8Q0JKgBN2fAhkUQtqK9LT7rEGR6+Wu/Rtbal1iI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= @@ -127,8 +159,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/exporter/bmchelixexporter/internal/operationsmanagement/metrics_client.go b/exporter/bmchelixexporter/internal/operationsmanagement/metrics_client.go new file mode 100644 index 000000000000..55b4535759b6 --- /dev/null +++ b/exporter/bmchelixexporter/internal/operationsmanagement/metrics_client.go @@ -0,0 +1,95 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package operationsmanagement // import "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/bmchelixexporter/internal/operationsmanagement" + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/config/confighttp" + "go.opentelemetry.io/collector/config/configopaque" + "go.uber.org/zap" +) + +// MetricsClient is responsible for sending the metrics payload to BMC Helix Operations Management +type MetricsClient struct { + url string + httpClient *http.Client + apiKey configopaque.String + logger *zap.Logger +} + +// NewMetricsClient creates a new MetricsClient +func NewMetricsClient(ctx context.Context, clientConfig confighttp.ClientConfig, apiKey configopaque.String, host component.Host, settings component.TelemetrySettings, logger *zap.Logger) (*MetricsClient, error) { + httpClient, err := clientConfig.ToClient(ctx, host, settings) + if err != nil { + return nil, err + } + return &MetricsClient{ + url: clientConfig.Endpoint + "/metrics-gateway-service/api/v1.0/insert", + httpClient: httpClient, + apiKey: apiKey, + logger: logger, + }, nil +} + +// SendHelixPayload sends the metrics payload to BMC Helix Operations Management +func (mc *MetricsClient) SendHelixPayload(ctx context.Context, payload []BMCHelixOMMetric) error { + if len(payload) == 0 { + mc.logger.Warn("Payload is empty, nothing to send") + return nil + } + + // Log the payload being sent + mc.logger.Debug("Sending payload to BMC Helix Operations Management", zap.Any("payload", payload)) + + // Get the JSON encoded payload + payloadBytes, err := json.Marshal(payload) + if err != nil { + mc.logger.Error("Failed to marshal metrics payload", zap.Error(err)) + return fmt.Errorf("failed to marshal payload: %w", err) + } + + // Create a new HTTP request to send the payload + req, err := mc.createNewHTTPRequest(ctx, payloadBytes) + if err != nil { + return err + } + + // Send the request + resp, err := mc.httpClient.Do(req) + if err != nil { + mc.logger.Error("Failed to send request to BMC Helix Operations Management", zap.Error(err)) + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Check the response status code + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + mc.logger.Error("Received non-2xx response from BMC Helix Operations Management", zap.Int("status_code", resp.StatusCode)) + return fmt.Errorf("received non-2xx response: %d", resp.StatusCode) + } + + mc.logger.Debug("Successfully sent payload to BMC Helix Operations Management", zap.String("url", mc.url)) + return nil +} + +// createNewHTTPRequest creates a new HTTP request with the payload +func (mc *MetricsClient) createNewHTTPRequest(ctx context.Context, payloadBytes []byte) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, mc.url, bytes.NewBuffer(payloadBytes)) + if err != nil { + mc.logger.Error("Failed to create HTTP request", zap.Error(err)) + return nil, err + } + + // Set required headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+string(mc.apiKey)) + + return req, nil +} diff --git a/exporter/bmchelixexporter/internal/operationsmanagement/metrics_client_test.go b/exporter/bmchelixexporter/internal/operationsmanagement/metrics_client_test.go new file mode 100644 index 000000000000..453fa55f7e3a --- /dev/null +++ b/exporter/bmchelixexporter/internal/operationsmanagement/metrics_client_test.go @@ -0,0 +1,217 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package operationsmanagement + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/collector/component/componenttest" + "go.opentelemetry.io/collector/config/confighttp" + "go.opentelemetry.io/collector/config/configopaque" + "go.uber.org/zap" +) + +func TestNewMetricsClient(t *testing.T) { + t.Parallel() + + endpoint := "https://helix1:8080" + var apiKey configopaque.String = "api_key" + + cfg := confighttp.NewDefaultClientConfig() + cfg.Endpoint = endpoint + cfg.Timeout = 10 * time.Second + + ctx := context.Background() + host := componenttest.NewNopHost() + settings := componenttest.NewNopTelemetrySettings() + + metricsClient, err := NewMetricsClient(ctx, cfg, apiKey, host, settings, zap.NewNop()) + assert.NoError(t, err) + assert.NotNil(t, metricsClient) + + assert.Equal(t, "https://helix1:8080/metrics-gateway-service/api/v1.0/insert", metricsClient.url) + assert.Equal(t, apiKey, metricsClient.apiKey) + assert.NotNil(t, metricsClient.httpClient) +} + +func TestSendHelixPayload200(t *testing.T) { + t.Parallel() + + // Mock payload + sample := BMCHelixOMSample{ + Value: 42, + Timestamp: 1634236000, + } + + metric := BMCHelixOMMetric{ + Labels: map[string]string{ + "isDeviceMappingEnabled": "true", + "entityTypeId": "test-entity-type-id", + "entityName": "test-entity", + "source": "OTEL", + "unit": "ms", + "hostType": "server", + "metricName": "test_metric", + "hostname": "test-hostname", + "instanceName": "test-entity-Name", + "entityId": "OTEL:test-hostname:test-entity-type-id:test-entity", + "parentEntityName": "test-entity-type-id_container", + "parentEntityTypeId": "test-entity-type-id_container", + }, + Samples: []BMCHelixOMSample{sample}, + } + + parent := BMCHelixOMMetric{ + Labels: map[string]string{ + "entityTypeId": "test-entity-type-id_container", + "entityName": "test-entity-type-id_container", + "isDeviceMappingEnabled": "true", + "source": "OTEL", + "hostType": "server", + "hostname": "test-hostname", + "entityId": "OTEL:test-hostname:test-entity-type-id_container:test-entity-type-id_container", + "metricName": "identity", + }, + Samples: []BMCHelixOMSample{}, + } + + payload := []BMCHelixOMMetric{parent, metric} + + var apiKey configopaque.String = "apiKey" + + // Create a mock HTTP server + mockServer := mockHTTPServer(t, apiKey, payload, http.StatusOK) + defer mockServer.Close() + + cfg := confighttp.NewDefaultClientConfig() + cfg.Endpoint = mockServer.URL + cfg.Timeout = 10 * time.Second + + ctx := context.Background() + host := componenttest.NewNopHost() + settings := componenttest.NewNopTelemetrySettings() + + client, err := NewMetricsClient(ctx, cfg, apiKey, host, settings, zap.NewNop()) + assert.NoError(t, err) + assert.NotNil(t, client) + + // Call SendHelixPayload + err = client.SendHelixPayload(ctx, payload) + assert.NoError(t, err) +} + +func TestSendHelixPayloadEmpty(t *testing.T) { + t.Parallel() + + cfg := confighttp.NewDefaultClientConfig() + cfg.Endpoint = "https://helix1:8080" + cfg.Timeout = 10 * time.Second + + ctx := context.Background() + host := componenttest.NewNopHost() + settings := componenttest.NewNopTelemetrySettings() + + client, err := NewMetricsClient(ctx, cfg, "apiKey", host, settings, zap.NewNop()) + assert.NoError(t, err) + assert.NotNil(t, client) + + // Call SendHelixPayload + err = client.SendHelixPayload(ctx, []BMCHelixOMMetric{}) + assert.NoError(t, err) +} + +func TestSendHelixPayload400(t *testing.T) { + t.Parallel() + + var apiKey configopaque.String = "apiKey" + payload := []BMCHelixOMMetric{ + { + Labels: map[string]string{}, + Samples: []BMCHelixOMSample{}, + }, + } + + // Create a mock HTTP server + mockServer := mockHTTPServer(t, apiKey, payload, http.StatusBadRequest) + defer mockServer.Close() + + cfg := confighttp.NewDefaultClientConfig() + cfg.Endpoint = mockServer.URL + cfg.Timeout = 10 * time.Second + + ctx := context.Background() + host := componenttest.NewNopHost() + settings := componenttest.NewNopTelemetrySettings() + + client, err := NewMetricsClient(ctx, cfg, apiKey, host, settings, zap.NewNop()) + assert.NoError(t, err) + assert.NotNil(t, client) + + // Call SendHelixPayload + err = client.SendHelixPayload(ctx, payload) + assert.Error(t, err) + assert.Equal(t, "received non-2xx response: 400", err.Error()) +} + +func TestSendHelixPayloadConnectionRefused(t *testing.T) { + t.Parallel() + + cfg := confighttp.NewDefaultClientConfig() + + // Generate a random available port + listener, err := net.Listen("tcp", "localhost:0") + assert.NoError(t, err) + listener.Close() + + randomPort := listener.Addr().(*net.TCPAddr).Port + cfg.Endpoint = "https://localhost:" + strconv.Itoa(randomPort) + cfg.Timeout = 500 * time.Millisecond + + ctx := context.Background() + host := componenttest.NewNopHost() + settings := componenttest.NewNopTelemetrySettings() + + client, err := NewMetricsClient(ctx, cfg, "apiKey", host, settings, zap.NewNop()) + assert.NoError(t, err) + assert.NotNil(t, client) + + payload := []BMCHelixOMMetric{ + { + Labels: map[string]string{}, + Samples: []BMCHelixOMSample{}, + }, + } + + // Call SendHelixPayload + err = client.SendHelixPayload(ctx, payload) + assert.Error(t, err) +} + +// mockHTTPServer creates a new mock HTTP server that verifies the request headers, body, and responds with the given status code +func mockHTTPServer(t *testing.T, apiKey configopaque.String, payload []BMCHelixOMMetric, httpStatusCode int) *httptest.Server { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify the request headers + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Equal(t, r.Header.Get("Authorization"), "Bearer "+string(apiKey)) + + // Verify the request body + var receivedPayload []BMCHelixOMMetric + err := json.NewDecoder(r.Body).Decode(&receivedPayload) + assert.NoError(t, err) + assert.NotEmpty(t, receivedPayload) + assert.Equal(t, payload, receivedPayload) + + // Respond with a success status + w.WriteHeader(httpStatusCode) + })) + return mockServer +} diff --git a/exporter/bmchelixexporter/internal/operationsmanagement/metrics_producer.go b/exporter/bmchelixexporter/internal/operationsmanagement/metrics_producer.go new file mode 100644 index 000000000000..a580d4e22946 --- /dev/null +++ b/exporter/bmchelixexporter/internal/operationsmanagement/metrics_producer.go @@ -0,0 +1,262 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package operationsmanagement // import "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/bmchelixexporter/internal/operationsmanagement" + +import ( + "fmt" + + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/pmetric" + conventions "go.opentelemetry.io/collector/semconv/v1.27.0" + "go.uber.org/zap" +) + +// BMCHelixOMMetric represents the structure of the payload that will be sent to BMC Helix Operations Management +type BMCHelixOMMetric struct { + Labels map[string]string `json:"labels"` + Samples []BMCHelixOMSample `json:"samples"` +} + +// BMCHelixOMSample represents the individual sample for a metric +type BMCHelixOMSample struct { + Value float64 `json:"value"` + Timestamp int64 `json:"timestamp"` +} + +// MetricsProducer is responsible for converting OpenTelemetry metrics into BMC Helix Operations Management metrics +type MetricsProducer struct { + logger *zap.Logger +} + +// NewMetricsProducer creates a new MetricsProducer +func NewMetricsProducer(logger *zap.Logger) *MetricsProducer { + return &MetricsProducer{ + logger: logger, + } +} + +// ProduceHelixPayload takes the OpenTelemetry metrics and converts them into the BMC Helix Operations Management metric format +func (mp *MetricsProducer) ProduceHelixPayload(metrics pmetric.Metrics) ([]BMCHelixOMMetric, error) { + helixMetrics := []BMCHelixOMMetric{} + containerParentEntities := map[string]BMCHelixOMMetric{} + + // Iterate through each pmetric.ResourceMetrics instance + rmetrics := metrics.ResourceMetrics() + for i := 0; i < rmetrics.Len(); i++ { + resourceMetric := rmetrics.At(i) + resource := resourceMetric.Resource() + + // Extract resource-level attributes (e.g., "host.name", "service.instance.id") + resourceAttrs := extractResourceAttributes(resource) + + // Iterate through each pmetric.ScopeMetrics within the pmetric.ResourceMetrics instance + scopeMetrics := resourceMetric.ScopeMetrics() + for j := 0; j < scopeMetrics.Len(); j++ { + scopeMetric := scopeMetrics.At(j) + + // Iterate through each individual pmetric.Metric instance + metrics := scopeMetric.Metrics() + for k := 0; k < metrics.Len(); k++ { + metric := metrics.At(k) + + // Create the payload for each metric + newHelixMetric, err := mp.createHelixMetric(metric, resourceAttrs) + if err != nil { + mp.logger.Warn("Failed to create Helix metric", zap.Error(err)) + continue + } + + helixMetrics = appendMetricWithParentEntity(helixMetrics, *newHelixMetric, containerParentEntities) + } + } + } + return helixMetrics, nil +} + +// appends the metric to the helixMetrics slice and creates a parent entity if it doesn't exist +func appendMetricWithParentEntity(helixMetrics []BMCHelixOMMetric, helixMetric BMCHelixOMMetric, containerParentEntities map[string]BMCHelixOMMetric) []BMCHelixOMMetric { + // Extract parent entity information + parentEntityTypeID := fmt.Sprintf("%s_container", helixMetric.Labels["entityTypeId"]) + parentEntityID := fmt.Sprintf("%s:%s:%s:%s", helixMetric.Labels["source"], helixMetric.Labels["hostname"], parentEntityTypeID, parentEntityTypeID) + + // Create a parent entity if not already created + if _, exists := containerParentEntities[parentEntityID]; !exists { + parentMetric := BMCHelixOMMetric{ + Labels: map[string]string{ + "entityId": parentEntityID, + "entityName": parentEntityTypeID, + "entityTypeId": parentEntityTypeID, + "hostname": helixMetric.Labels["hostname"], + "source": helixMetric.Labels["source"], + "isDeviceMappingEnabled": helixMetric.Labels["isDeviceMappingEnabled"], + "hostType": helixMetric.Labels["hostType"], + "metricName": "identity", // Represents the parent entity itself + }, + Samples: []BMCHelixOMSample{}, // Parent entities don't have samples + } + containerParentEntities[parentEntityID] = parentMetric + helixMetrics = append(helixMetrics, parentMetric) + } + + // Add parent reference to the child metric + helixMetric.Labels["parentEntityName"] = parentEntityTypeID + helixMetric.Labels["parentEntityTypeId"] = parentEntityTypeID + + return append(helixMetrics, helixMetric) +} + +// createHelixMetric converts a single OpenTelemetry metric into a BMCHelixOMMetric payload +func (mp *MetricsProducer) createHelixMetric(metric pmetric.Metric, resourceAttrs map[string]string) (*BMCHelixOMMetric, error) { + labels := make(map[string]string) + labels["source"] = "OTEL" + + // Add resource attributes as labels + for k, v := range resourceAttrs { + labels[k] = v + } + + // Set the metric unit + labels["unit"] = metric.Unit() + + // Set the host type + labels["hostType"] = "server" + + // Indicates the monitor in the hierarchy that is mapped to the device + labels["isDeviceMappingEnabled"] = "true" + + // Update the metric name for the BMC Helix Operations Management payload + labels["metricName"] = metric.Name() + + // Samples to hold the metric values + samples := []BMCHelixOMSample{} + + // Handle different types of metrics (sum and gauge) + // BMC Helix Operations Management only supports simple metrics (sum, gauge, etc.) and not histograms or summaries + switch metric.Type() { + case pmetric.MetricTypeSum: + dataPoints := metric.Sum().DataPoints() + for i := 0; i < dataPoints.Len(); i++ { + samples = mp.processDatapoint(samples, dataPoints.At(i), labels, metric, resourceAttrs) + } + case pmetric.MetricTypeGauge: + dataPoints := metric.Gauge().DataPoints() + for i := 0; i < dataPoints.Len(); i++ { + samples = mp.processDatapoint(samples, dataPoints.At(i), labels, metric, resourceAttrs) + } + default: + return nil, fmt.Errorf("unsupported metric type %s", metric.Type()) + } + + // Check if the hostname is set + if labels["hostname"] == "" { + return nil, fmt.Errorf("hostname is required for the BMC Helix Operations Management payload but not set for metric %s", metric.Name()) + } + + // Check if the entityTypeId is set + if labels["entityTypeId"] == "" { + return nil, fmt.Errorf("entityTypeId is required for the BMC Helix Operations Management payload but not set for metric %s", metric.Name()) + } + + // Check if the entityName is set + if labels["entityName"] == "" { + return nil, fmt.Errorf("entityName is required for the BMC Helix Operations Management payload but not set for metric %s", metric.Name()) + } + + return &BMCHelixOMMetric{ + Labels: labels, + Samples: samples, + }, nil +} + +// Updates the metric information for the BMC Helix Operations Management payload and returns the updated samples +func (mp *MetricsProducer) processDatapoint(samples []BMCHelixOMSample, dp pmetric.NumberDataPoint, labels map[string]string, metric pmetric.Metric, resourceAttrs map[string]string) []BMCHelixOMSample { + // Update the entity information for the BMC Helix Operations Management payload + err := mp.updateEntityInformation(labels, metric.Name(), resourceAttrs, dp.Attributes().AsRaw()) + if err != nil { + mp.logger.Warn("Failed to update entity information", zap.Error(err)) + } + + return append(samples, newSample(dp)) +} + +// Update the entity information for the BMC Helix Operations Management payload +func (mp *MetricsProducer) updateEntityInformation(labels map[string]string, metricName string, resourceAttrs map[string]string, dpAttributes map[string]any) error { + // Try to get the hostname from resource attributes first + hostname, found := resourceAttrs[conventions.AttributeHostName] + if !found || hostname == "" { + // Fallback to metric attributes if not found or empty in resource attributes + if maybeHostname, ok := dpAttributes[conventions.AttributeHostName].(string); ok && maybeHostname != "" { + hostname = maybeHostname + } else { + return fmt.Errorf("the hostname is required for the BMC Helix Operations Management payload but not set for metric %s. Metric datapoint will be skipped", metricName) + } + } + + // Add the hostname as a label (required for BMC Helix Operations Management payload) + labels["hostname"] = hostname + + // Convert metricAttrs from map[string]any to map[string]string for compatibility + stringMetricAttrs := make(map[string]string) + for k, v := range dpAttributes { + stringMetricAttrs[k] = fmt.Sprintf("%v", v) + labels[k] = fmt.Sprintf("%v", v) + } + + // Add the resource attributes to the metric attributes + for k, v := range resourceAttrs { + stringMetricAttrs[k] = v + } + + // entityTypeId is required for the BMC Helix Operations Management payload + entityTypeID := stringMetricAttrs["entityTypeId"] + if entityTypeID == "" { + return fmt.Errorf("the entityTypeId is required for the BMC Helix Operations Management payload but not set for metric %s. Metric datapoint will be skipped", metricName) + } + + // entityName is required for the BMC Helix Operations Management payload + entityName := stringMetricAttrs["entityName"] + if entityName == "" { + return fmt.Errorf("the entityName is required for the BMC Helix Operations Management payload but not set for metric %s. Metric datapoint will be skipped", metricName) + } + + instanceName := stringMetricAttrs["instanceName"] + if instanceName == "" { + instanceName = entityName + } + + // Set the entityTypeId, entityId, instanceName and entityName in labels + labels["entityTypeId"] = entityTypeID + labels["entityName"] = entityName + labels["instanceName"] = instanceName + labels["entityId"] = fmt.Sprintf("%s:%s:%s:%s", labels["source"], labels["hostname"], entityTypeID, entityName) + return nil +} + +// newSample creates a new BMCHelixOMSample from the OpenTelemetry data point +func newSample(dp pmetric.NumberDataPoint) BMCHelixOMSample { + var value float64 + switch dp.ValueType() { + case pmetric.NumberDataPointValueTypeDouble: + value = dp.DoubleValue() + case pmetric.NumberDataPointValueTypeInt: + value = float64(dp.IntValue()) // convert int to float for consistency + } + + return BMCHelixOMSample{ + Value: value, + Timestamp: dp.Timestamp().AsTime().Unix() * 1000, + } +} + +// extractResourceAttributes extracts the resource attributes from OpenTelemetry resource data +func extractResourceAttributes(resource pcommon.Resource) map[string]string { + attributes := make(map[string]string) + + resource.Attributes().Range(func(k string, v pcommon.Value) bool { + attributes[k] = v.AsString() + return true + }) + + return attributes +} diff --git a/exporter/bmchelixexporter/internal/operationsmanagement/metrics_producer_test.go b/exporter/bmchelixexporter/internal/operationsmanagement/metrics_producer_test.go new file mode 100644 index 000000000000..801385aa580b --- /dev/null +++ b/exporter/bmchelixexporter/internal/operationsmanagement/metrics_producer_test.go @@ -0,0 +1,120 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package operationsmanagement + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/collector/pdata/pmetric" + conventions "go.opentelemetry.io/collector/semconv/v1.27.0" + "go.uber.org/zap" +) + +// Test for the ProduceHelixPayload method +func TestProduceHelixPayload(t *testing.T) { + t.Parallel() + + sample := BMCHelixOMSample{ + Value: 42, + Timestamp: 1634236000, + } + + metric := BMCHelixOMMetric{ + Labels: map[string]string{ + "isDeviceMappingEnabled": "true", + "entityTypeId": "test-entity-type-id", + "entityName": "test-entity", + "source": "OTEL", + "unit": "s", + "hostType": "server", + "metricName": "test_metric", + "hostname": "test-hostname", + "instanceName": "test-entity-Name", + "entityId": "OTEL:test-hostname:test-entity-type-id:test-entity", + "parentEntityName": "test-entity-type-id_container", + "parentEntityTypeId": "test-entity-type-id_container", + "host.name": "test-hostname", + }, + Samples: []BMCHelixOMSample{sample}, + } + + parent := BMCHelixOMMetric{ + Labels: map[string]string{ + "entityTypeId": "test-entity-type-id_container", + "entityName": "test-entity-type-id_container", + "isDeviceMappingEnabled": "true", + "source": "OTEL", + "hostType": "server", + "hostname": "test-hostname", + "entityId": "OTEL:test-hostname:test-entity-type-id_container:test-entity-type-id_container", + "metricName": "identity", + }, + Samples: []BMCHelixOMSample{}, + } + + expectedPayload := []BMCHelixOMMetric{parent, metric} + + producer := NewMetricsProducer(zap.NewExample()) + + tests := []struct { + name string + generateMockMetrics func() pmetric.Metrics + expectedPayload []BMCHelixOMMetric + }{ + { + name: "SetGauge", + generateMockMetrics: func() pmetric.Metrics { + return generateMockMetrics(func(metric pmetric.Metric) pmetric.NumberDataPoint { + return metric.SetEmptyGauge().DataPoints().AppendEmpty() + }) + }, + expectedPayload: expectedPayload, + }, + { + name: "SetSum", + generateMockMetrics: func() pmetric.Metrics { + return generateMockMetrics(func(metric pmetric.Metric) pmetric.NumberDataPoint { + return metric.SetEmptySum().DataPoints().AppendEmpty() + }) + }, + expectedPayload: expectedPayload, + }, + { + name: "emptyPayload", + generateMockMetrics: pmetric.NewMetrics, + expectedPayload: []BMCHelixOMMetric{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockMetrics := tt.generateMockMetrics() + payload, err := producer.ProduceHelixPayload(mockMetrics) + assert.NoError(t, err, "Expected no error during payload production") + assert.NotNil(t, payload, "Payload should not be nil") + + assert.Equal(t, tt.expectedPayload, payload, "Payload should match the expected payload") + }) + } +} + +// Mock data generation for testing +func generateMockMetrics(dpCreator func(metric pmetric.Metric) pmetric.NumberDataPoint) pmetric.Metrics { + metrics := pmetric.NewMetrics() + rm := metrics.ResourceMetrics().AppendEmpty() + il := rm.ScopeMetrics().AppendEmpty().Metrics() + metric := il.AppendEmpty() + metric.SetName("test_metric") + metric.SetDescription("This is a test metric") + metric.SetUnit("s") + dp := dpCreator(metric) + dp.Attributes().PutStr(conventions.AttributeHostName, "test-hostname") + dp.Attributes().PutStr("entityName", "test-entity") + dp.Attributes().PutStr("entityTypeId", "test-entity-type-id") + dp.Attributes().PutStr("instanceName", "test-entity-Name") + dp.SetTimestamp(1634236000000000) // Example timestamp + dp.SetDoubleValue(42.0) + return metrics +}