Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[exporter/azuremonitor] Add Connection String Support to Azure Monitor Exporter #28854

Merged
merged 10 commits into from
Nov 3, 2023
27 changes: 27 additions & 0 deletions .chloggen/add-connection-string-azure-monitor-exporter.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Use this changelog template to create an entry for release notes.

# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
change_type: enhancement

# The name of the component, or a single word describing the area of concern, (e.g. filelogreceiver)
component: azuremonitorexporter

# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
note: Added connection string support to the Azure Monitor Exporter

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

# (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: This enhancement simplifies the configuration process and aligns the exporter with Azure Monitor's recommended practices.
rajkumar-rangaraj marked this conversation as resolved.
Show resolved Hide resolved

# 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]
2 changes: 2 additions & 0 deletions cmd/otelcontribcol/exporters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/stretchr/testify/require"
"go.opentelemetry.io/collector/component"
"go.opentelemetry.io/collector/config/configgrpc"
"go.opentelemetry.io/collector/config/configopaque"
"go.opentelemetry.io/collector/exporter"
"go.opentelemetry.io/collector/exporter/exportertest"
"go.opentelemetry.io/collector/exporter/otlpexporter"
Expand Down Expand Up @@ -298,6 +299,7 @@ func TestDefaultExporters(t *testing.T) {
getConfigFn: func() component.Config {
cfg := expFactories["azuremonitor"].CreateDefaultConfig().(*azuremonitorexporter.Config)
cfg.Endpoint = "http://" + endpoint
cfg.ConnectionString = configopaque.String("InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=" + cfg.Endpoint)

return cfg
},
Expand Down
2 changes: 1 addition & 1 deletion cmd/otelcontribcol/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ require (
github.com/stretchr/testify v1.8.4
go.opentelemetry.io/collector/component v0.88.1-0.20231026220224-6405e152a2d9
go.opentelemetry.io/collector/config/configgrpc v0.88.1-0.20231026220224-6405e152a2d9
go.opentelemetry.io/collector/config/configopaque v0.88.1-0.20231026220224-6405e152a2d9
go.opentelemetry.io/collector/confmap v0.88.1-0.20231026220224-6405e152a2d9
go.opentelemetry.io/collector/connector v0.88.1-0.20231026220224-6405e152a2d9
go.opentelemetry.io/collector/connector/forwardconnector v0.88.1-0.20231026220224-6405e152a2d9
Expand Down Expand Up @@ -636,7 +637,6 @@ require (
go.opentelemetry.io/collector/config/configcompression v0.88.1-0.20231026220224-6405e152a2d9 // indirect
go.opentelemetry.io/collector/config/confighttp v0.88.1-0.20231026220224-6405e152a2d9 // indirect
go.opentelemetry.io/collector/config/confignet v0.88.1-0.20231026220224-6405e152a2d9 // indirect
go.opentelemetry.io/collector/config/configopaque v0.88.1-0.20231026220224-6405e152a2d9 // indirect
go.opentelemetry.io/collector/config/configtelemetry v0.88.1-0.20231026220224-6405e152a2d9 // indirect
go.opentelemetry.io/collector/config/configtls v0.88.1-0.20231026220224-6405e152a2d9 // indirect
go.opentelemetry.io/collector/config/internal v0.88.1-0.20231026220224-6405e152a2d9 // indirect
Expand Down
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 {
ingestionURL, err := url.Parse(ingestionEndpoint)
if err != nil {
ingestionURL, _ = url.Parse(DefaultIngestionEndpoint)
}

ingestionURL.Path = path.Join(ingestionURL.Path, "/v2/track")
return ingestionURL.String()
}
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