Skip to content

Commit

Permalink
feat(azuremonitorexporter): add support for connection strings
Browse files Browse the repository at this point in the history
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: open-telemetry#28853 (Add ConnectionString Support for Azure Monitor Exporter)
  • Loading branch information
rajkumar-rangaraj committed Nov 1, 2023
1 parent f530bc4 commit 391d829
Show file tree
Hide file tree
Showing 7 changed files with 251 additions and 9 deletions.
1 change: 1 addition & 0 deletions exporter/azuremonitorexporter/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
3 changes: 2 additions & 1 deletion exporter/azuremonitorexporter/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
80 changes: 80 additions & 0 deletions exporter/azuremonitorexporter/connection_string_parser.go
Original file line number Diff line number Diff line change
@@ -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
}
134 changes: 134 additions & 0 deletions exporter/azuremonitorexporter/connection_string_parser_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
}
30 changes: 25 additions & 5 deletions exporter/azuremonitorexporter/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
}

Expand All @@ -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)
}

Expand All @@ -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
Expand All @@ -120,5 +140,5 @@ func (f *factory) getTransportChannel(exporterConfig *Config, logger *zap.Logger
}
}

return f.tChannel
return f.tChannel, nil
}
8 changes: 6 additions & 2 deletions exporter/azuremonitorexporter/factory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion exporter/azuremonitorexporter/testdata/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

0 comments on commit 391d829

Please sign in to comment.