From 391d829f05bee09d25b98fd65bd12f18745b101f Mon Sep 17 00:00:00 2001 From: rajkumar-rangaraj Date: Wed, 1 Nov 2023 15:08:02 -0700 Subject: [PATCH] feat(azuremonitorexporter): add support for connection strings This commit introduces the ability to configure the Azure Monitor Exporter using a connection string. The connection string simplifies the configuration by encapsulating various settings into a single string, making it easier for users to configure the exporter. Changes: - Update the Config struct to include a ConnectionString field. - Modify the factory's getTransportChannel method to parse the connection string and apply the settings to the exporter configuration. - Update the tests to cover the new connection string functionality. - Update the documentation to provide examples of how to use the connection string for configuration. By supporting connection strings, users now have a more straightforward way to configure the Azure Monitor Exporter, aligning with Azure Monitor's standard practices. Refs: #28853 (Add ConnectionString Support for Azure Monitor Exporter) --- exporter/azuremonitorexporter/config.go | 1 + exporter/azuremonitorexporter/config_test.go | 3 +- .../connection_string_parser.go | 80 +++++++++++ .../connection_string_parser_test.go | 134 ++++++++++++++++++ exporter/azuremonitorexporter/factory.go | 30 +++- exporter/azuremonitorexporter/factory_test.go | 8 +- .../azuremonitorexporter/testdata/config.yaml | 4 +- 7 files changed, 251 insertions(+), 9 deletions(-) create mode 100644 exporter/azuremonitorexporter/connection_string_parser.go create mode 100644 exporter/azuremonitorexporter/connection_string_parser_test.go diff --git a/exporter/azuremonitorexporter/config.go b/exporter/azuremonitorexporter/config.go index 6c5042954344..058a28851de5 100644 --- a/exporter/azuremonitorexporter/config.go +++ b/exporter/azuremonitorexporter/config.go @@ -12,6 +12,7 @@ import ( // Config defines configuration for Azure Monitor type Config struct { Endpoint string `mapstructure:"endpoint"` + ConnectionString configopaque.String `mapstructure:"connection_string"` InstrumentationKey configopaque.String `mapstructure:"instrumentation_key"` MaxBatchSize int `mapstructure:"maxbatchsize"` MaxBatchInterval time.Duration `mapstructure:"maxbatchinterval"` diff --git a/exporter/azuremonitorexporter/config_test.go b/exporter/azuremonitorexporter/config_test.go index 0e271073e400..ac961c05d03c 100644 --- a/exporter/azuremonitorexporter/config_test.go +++ b/exporter/azuremonitorexporter/config_test.go @@ -35,7 +35,8 @@ func TestLoadConfig(t *testing.T) { id: component.NewIDWithName(metadata.Type, "2"), expected: &Config{ Endpoint: defaultEndpoint, - InstrumentationKey: "abcdefg", + ConnectionString: "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingestion.azuremonitor.com/", + InstrumentationKey: "00000000-0000-0000-0000-000000000000", MaxBatchSize: 100, MaxBatchInterval: 10 * time.Second, SpanEventsEnabled: false, diff --git a/exporter/azuremonitorexporter/connection_string_parser.go b/exporter/azuremonitorexporter/connection_string_parser.go new file mode 100644 index 000000000000..23e87fdba2f7 --- /dev/null +++ b/exporter/azuremonitorexporter/connection_string_parser.go @@ -0,0 +1,80 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package azuremonitorexporter // import "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/azuremonitorexporter" + +import ( + "fmt" + "net/url" + "path" + "strings" +) + +type ConnectionVars struct { + InstrumentationKey string + IngestionUrl string +} + +const ( + DefaultIngestionEndpoint = "https://dc.services.visualstudio.com/" + IngestionEndpointKey = "IngestionEndpoint" + InstrumentationKey = "InstrumentationKey" + ConnectionStringMaxLength = 4096 +) + +func parseConnectionString(exporterConfig *Config) (*ConnectionVars, error) { + connectionString := string(exporterConfig.ConnectionString) + instrumentationKey := string(exporterConfig.InstrumentationKey) + connectionVars := &ConnectionVars{} + + if connectionString == "" && instrumentationKey == "" { + return nil, fmt.Errorf("ConnectionString and InstrumentationKey cannot be empty") + } + if len(connectionString) > ConnectionStringMaxLength { + return nil, fmt.Errorf("ConnectionString exceeds maximum length of %d characters", ConnectionStringMaxLength) + } + if connectionString == "" { + connectionVars.InstrumentationKey = instrumentationKey + connectionVars.IngestionUrl, _ = getIngestionURL(DefaultIngestionEndpoint) + return connectionVars, nil + } + + pairs := strings.Split(connectionString, ";") + values := make(map[string]string) + for _, pair := range pairs { + kv := strings.SplitN(strings.TrimSpace(pair), "=", 2) + if len(kv) != 2 { + return nil, fmt.Errorf("invalid format for connection string: %s", pair) + } + + key, value := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]) + if key == "" { + return nil, fmt.Errorf("key cannot be empty") + } + values[key] = value + } + + var ok bool + if connectionVars.InstrumentationKey, ok = values[InstrumentationKey]; !ok || connectionVars.InstrumentationKey == "" { + return nil, fmt.Errorf("%s is required", InstrumentationKey) + } + + var ingestionEndpoint string + if ingestionEndpoint, ok = values[IngestionEndpointKey]; !ok || ingestionEndpoint == "" { + ingestionEndpoint = DefaultIngestionEndpoint + } + + connectionVars.IngestionUrl, _ = getIngestionURL(ingestionEndpoint) + + return connectionVars, nil +} + +func getIngestionURL(ingestionEndpoint string) (string, error) { + ingestionURL, err := url.Parse(ingestionEndpoint) + if err != nil { + ingestionURL, _ = url.Parse(DefaultIngestionEndpoint) + } + + ingestionURL.Path = path.Join(ingestionURL.Path, "/v2/track") + return ingestionURL.String(), nil +} diff --git a/exporter/azuremonitorexporter/connection_string_parser_test.go b/exporter/azuremonitorexporter/connection_string_parser_test.go new file mode 100644 index 000000000000..ad23870f648a --- /dev/null +++ b/exporter/azuremonitorexporter/connection_string_parser_test.go @@ -0,0 +1,134 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package azuremonitorexporter + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/config/configopaque" +) + +func TestParseConnectionString(t *testing.T) { + tests := []struct { + name string + config *Config + want *ConnectionVars + wantError bool + }{ + { + name: "Valid connection string and instrumentation key", + config: &Config{ + ConnectionString: "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingestion.azuremonitor.com/", + InstrumentationKey: "00000000-0000-0000-0000-00000000IKEY", + }, + want: &ConnectionVars{ + InstrumentationKey: "00000000-0000-0000-0000-000000000000", + IngestionUrl: "https://ingestion.azuremonitor.com/v2/track", + }, + wantError: false, + }, + { + name: "Empty connection string with valid instrumentation key", + config: &Config{ + InstrumentationKey: "00000000-0000-0000-0000-000000000000", + }, + want: &ConnectionVars{ + InstrumentationKey: "00000000-0000-0000-0000-000000000000", + IngestionUrl: DefaultIngestionEndpoint + "v2/track", + }, + wantError: false, + }, + { + name: "Valid connection string with empty instrumentation key", + config: &Config{ + ConnectionString: "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingestion.azuremonitor.com/", + }, + want: &ConnectionVars{ + InstrumentationKey: "00000000-0000-0000-0000-000000000000", + IngestionUrl: "https://ingestion.azuremonitor.com/v2/track", + }, + wantError: false, + }, + { + name: "Empty connection string and instrumentation key", + config: &Config{ + ConnectionString: "", + InstrumentationKey: "", + }, + want: nil, + wantError: true, + }, + { + name: "Invalid connection string format", + config: &Config{ + ConnectionString: "InvalidConnectionString", + }, + want: nil, + wantError: true, + }, + { + name: "Missing InstrumentationKey in connection string", + config: &Config{ + ConnectionString: "IngestionEndpoint=https://ingestion.azuremonitor.com/", + }, + want: nil, + wantError: true, + }, + { + name: "Empty InstrumentationKey in connection string", + config: &Config{ + ConnectionString: "InstrumentationKey=;IngestionEndpoint=https://ingestion.azuremonitor.com/", + }, + want: nil, + wantError: true, + }, + { + name: "Extra parameters in connection string", + config: &Config{ + ConnectionString: "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingestion.azuremonitor.com/;ExtraParam=extra", + }, + want: &ConnectionVars{ + InstrumentationKey: "00000000-0000-0000-0000-000000000000", + IngestionUrl: "https://ingestion.azuremonitor.com/v2/track", + }, + wantError: false, + }, + { + name: "Spaces around equals in connection string", + config: &Config{ + ConnectionString: "InstrumentationKey = 00000000-0000-0000-0000-000000000000 ; IngestionEndpoint = https://ingestion.azuremonitor.com/", + }, + want: &ConnectionVars{ + InstrumentationKey: "00000000-0000-0000-0000-000000000000", + IngestionUrl: "https://ingestion.azuremonitor.com/v2/track", + }, + wantError: false, + }, + { + name: "Connection string too long", + config: &Config{ + ConnectionString: configopaque.String(strings.Repeat("a", ConnectionStringMaxLength+1)), + }, + want: nil, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseConnectionString(tt.config) + if tt.wantError { + require.Error(t, err, "Expected an error but got none") + } else { + require.NoError(t, err, "Unexpected error: %v", err) + require.NotNil(t, got, "Expected a non-nil result") + assert.Equal(t, tt.want.InstrumentationKey, got.InstrumentationKey, "InstrumentationKey does not match") + assert.Equal(t, tt.want.IngestionUrl, got.IngestionUrl, "IngestionEndpoint does not match") + } + }) + } +} diff --git a/exporter/azuremonitorexporter/factory.go b/exporter/azuremonitorexporter/factory.go index aee00000a27c..119a8c2f858f 100644 --- a/exporter/azuremonitorexporter/factory.go +++ b/exporter/azuremonitorexporter/factory.go @@ -12,6 +12,7 @@ import ( "github.com/microsoft/ApplicationInsights-Go/appinsights" "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/config/configopaque" "go.opentelemetry.io/collector/exporter" "go.uber.org/zap" @@ -62,7 +63,11 @@ func (f *factory) createTracesExporter( return nil, errUnexpectedConfigurationType } - tc := f.getTransportChannel(exporterConfig, set.Logger) + tc, errInstrumentationKeyOrConnectionString := f.getTransportChannel(exporterConfig, set.Logger) + if errInstrumentationKeyOrConnectionString != nil { + return nil, errInstrumentationKeyOrConnectionString + } + return newTracesExporter(exporterConfig, tc, set) } @@ -77,7 +82,11 @@ func (f *factory) createLogsExporter( return nil, errUnexpectedConfigurationType } - tc := f.getTransportChannel(exporterConfig, set.Logger) + tc, errInstrumentationKeyOrConnectionString := f.getTransportChannel(exporterConfig, set.Logger) + if errInstrumentationKeyOrConnectionString != nil { + return nil, errInstrumentationKeyOrConnectionString + } + return newLogsExporter(exporterConfig, tc, set) } @@ -92,17 +101,28 @@ func (f *factory) createMetricsExporter( return nil, errUnexpectedConfigurationType } - tc := f.getTransportChannel(exporterConfig, set.Logger) + tc, errInstrumentationKeyOrConnectionString := f.getTransportChannel(exporterConfig, set.Logger) + if errInstrumentationKeyOrConnectionString != nil { + return nil, errInstrumentationKeyOrConnectionString + } + return newMetricsExporter(exporterConfig, tc, set) } // Configures the transport channel. // This method is not thread-safe -func (f *factory) getTransportChannel(exporterConfig *Config, logger *zap.Logger) transportChannel { +func (f *factory) getTransportChannel(exporterConfig *Config, logger *zap.Logger) (transportChannel, error) { // The default transport channel uses the default send mechanism from the AppInsights telemetry client. // This default channel handles batching, appropriate retries, and is backed by memory. if f.tChannel == nil { + connectionVars, err := parseConnectionString(exporterConfig) + if err != nil { + return nil, err + } + + exporterConfig.InstrumentationKey = configopaque.String(connectionVars.InstrumentationKey) + exporterConfig.Endpoint = connectionVars.IngestionUrl telemetryConfiguration := appinsights.NewTelemetryConfiguration(string(exporterConfig.InstrumentationKey)) telemetryConfiguration.EndpointUrl = exporterConfig.Endpoint telemetryConfiguration.MaxBatchSize = exporterConfig.MaxBatchSize @@ -120,5 +140,5 @@ func (f *factory) getTransportChannel(exporterConfig *Config, logger *zap.Logger } } - return f.tChannel + return f.tChannel, nil } diff --git a/exporter/azuremonitorexporter/factory_test.go b/exporter/azuremonitorexporter/factory_test.go index 9825b61bd0e1..5fa5bd0f2f86 100644 --- a/exporter/azuremonitorexporter/factory_test.go +++ b/exporter/azuremonitorexporter/factory_test.go @@ -20,7 +20,9 @@ func TestCreateTracesExporterUsingSpecificTransportChannel(t *testing.T) { f := factory{tChannel: &mockTransportChannel{}} ctx := context.Background() params := exportertest.NewNopCreateSettings() - exporter, err := f.createTracesExporter(ctx, params, createDefaultConfig()) + config := createDefaultConfig().(*Config) + config.ConnectionString = "InstrumentationKey=test-key;IngestionEndpoint=https://test-endpoint/" + exporter, err := f.createTracesExporter(ctx, params, config) assert.NotNil(t, exporter) assert.Nil(t, err) } @@ -30,7 +32,9 @@ func TestCreateTracesExporterUsingDefaultTransportChannel(t *testing.T) { f := factory{} assert.Nil(t, f.tChannel) ctx := context.Background() - exporter, err := f.createTracesExporter(ctx, exportertest.NewNopCreateSettings(), createDefaultConfig()) + config := createDefaultConfig().(*Config) + config.ConnectionString = "InstrumentationKey=test-key;IngestionEndpoint=https://test-endpoint/" + exporter, err := f.createTracesExporter(ctx, exportertest.NewNopCreateSettings(), config) assert.NotNil(t, exporter) assert.Nil(t, err) assert.NotNil(t, f.tChannel) diff --git a/exporter/azuremonitorexporter/testdata/config.yaml b/exporter/azuremonitorexporter/testdata/config.yaml index c182f3bd8ae2..1a8a01a3ddb3 100644 --- a/exporter/azuremonitorexporter/testdata/config.yaml +++ b/exporter/azuremonitorexporter/testdata/config.yaml @@ -3,7 +3,9 @@ azuremonitor/2: # endpoint is the uri used to communicate with Azure Monitor endpoint: "https://dc.services.visualstudio.com/v2/track" # instrumentation_key is the unique identifer for your Application Insights resource - instrumentation_key: abcdefg + instrumentation_key: 00000000-0000-0000-0000-000000000000 + # connection string specifies Application Insights InstrumentationKey and IngestionEndpoint + connection_string: InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingestion.azuremonitor.com/ # maxbatchsize is the maximum number of items that can be queued before calling to the configured endpoint maxbatchsize: 100 # maxbatchinterval is the maximum time to wait before calling the configured endpoint.