diff --git a/exporter/datadogexporter/config/config.go b/exporter/datadogexporter/config/config.go index 1f436e071c1b..88278ecc9ac9 100644 --- a/exporter/datadogexporter/config/config.go +++ b/exporter/datadogexporter/config/config.go @@ -18,6 +18,7 @@ import ( "errors" "fmt" "strings" + "sync" "go.opentelemetry.io/collector/config/configmodels" "go.opentelemetry.io/collector/config/confignet" @@ -99,28 +100,12 @@ type TagsConfig struct { Tags []string `mapstructure:"tags"` } -// GetTags gets the default tags extracted from the configuration -func (t *TagsConfig) GetTags(addHost bool) []string { - tags := make([]string, 0, 4) - - vars := map[string]string{ - "env": t.Env, - "service": t.Service, - "version": t.Version, - } - - if addHost { - vars["host"] = t.Hostname +// GetHostTags gets the host tags extracted from the configuration +func (t *TagsConfig) GetHostTags() []string { + tags := t.Tags + if t.Env != "none" { + tags = append(tags, fmt.Sprintf("env:%s", t.Env)) } - - for name, val := range vars { - if val != "" { - tags = append(tags, fmt.Sprintf("%s:%s", name, val)) - } - } - - tags = append(tags, t.Tags...) - return tags } @@ -138,6 +123,16 @@ type Config struct { // Traces defines the Traces exporter specific configuration Traces TracesConfig `mapstructure:"traces"` + + // SendMetadata defines whether to send host metadata + SendMetadata bool `mapstructure:"send_metadata"` + + // onceMetadata ensures only one exporter (metrics/traces) sends host metadata + onceMetadata sync.Once +} + +func (c *Config) OnceMetadata() *sync.Once { + return &c.onceMetadata } // Sanitize tries to sanitize a given configuration diff --git a/exporter/datadogexporter/config/config_test.go b/exporter/datadogexporter/config/config_test.go index 9f6ffcb1592e..bcb6ac5f161b 100644 --- a/exporter/datadogexporter/config/config_test.go +++ b/exporter/datadogexporter/config/config_test.go @@ -22,25 +22,23 @@ import ( "go.opentelemetry.io/collector/config/confignet" ) -func TestTags(t *testing.T) { +func TestHostTags(t *testing.T) { tc := TagsConfig{ Hostname: "customhost", Env: "customenv", - Service: "customservice", - Version: "customversion", - Tags: []string{"key1:val1", "key2:val2"}, + // Service and version should be only used for traces + Service: "customservice", + Version: "customversion", + Tags: []string{"key1:val1", "key2:val2"}, } assert.ElementsMatch(t, []string{ - "host:customhost", "env:customenv", - "service:customservice", - "version:customversion", "key1:val1", "key2:val2", }, - tc.GetTags(true), // get host + tc.GetHostTags(), ) } diff --git a/exporter/datadogexporter/factory.go b/exporter/datadogexporter/factory.go index 3d9ef6ffb645..e3d5542650a5 100644 --- a/exporter/datadogexporter/factory.go +++ b/exporter/datadogexporter/factory.go @@ -23,6 +23,7 @@ import ( "go.opentelemetry.io/collector/exporter/exporterhelper" "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datadogexporter/config" + "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datadogexporter/metadata" ) const ( @@ -65,12 +66,14 @@ func createDefaultConfig() configmodels.Exporter { Endpoint: "", // set during config sanitization }, }, + + SendMetadata: true, } } // createMetricsExporter creates a metrics exporter based on this config. func createMetricsExporter( - _ context.Context, + ctx context.Context, params component.ExporterCreateParams, c configmodels.Exporter, ) (component.MetricsExporter, error) { @@ -82,22 +85,34 @@ func createMetricsExporter( return nil, err } - exp, err := newMetricsExporter(params.Logger, cfg) + exp, err := newMetricsExporter(params, cfg) if err != nil { return nil, err } + ctx, cancel := context.WithCancel(ctx) + if cfg.SendMetadata { + once := cfg.OnceMetadata() + once.Do(func() { + go metadata.Pusher(ctx, params, cfg) + }) + } + return exporterhelper.NewMetricsExporter( cfg, exp.PushMetricsData, exporterhelper.WithQueue(exporterhelper.CreateDefaultQueueSettings()), exporterhelper.WithRetry(exporterhelper.CreateDefaultRetrySettings()), + exporterhelper.WithShutdown(func(context.Context) error { + cancel() + return nil + }), ) } // createTraceExporter creates a trace exporter based on this config. func createTraceExporter( - _ context.Context, + ctx context.Context, params component.ExporterCreateParams, c configmodels.Exporter, ) (component.TraceExporter, error) { @@ -109,13 +124,25 @@ func createTraceExporter( return nil, err } - exp, err := newTraceExporter(params.Logger, cfg) + exp, err := newTraceExporter(params, cfg) if err != nil { return nil, err } + ctx, cancel := context.WithCancel(ctx) + if cfg.SendMetadata { + once := cfg.OnceMetadata() + once.Do(func() { + go metadata.Pusher(ctx, params, cfg) + }) + } + return exporterhelper.NewTraceExporter( cfg, exp.pushTraceData, + exporterhelper.WithShutdown(func(context.Context) error { + cancel() + return nil + }), ) } diff --git a/exporter/datadogexporter/factory_test.go b/exporter/datadogexporter/factory_test.go index 1f73efad6102..296a61b6d63b 100644 --- a/exporter/datadogexporter/factory_test.go +++ b/exporter/datadogexporter/factory_test.go @@ -48,6 +48,7 @@ func TestCreateDefaultConfig(t *testing.T) { Traces: config.TracesConfig{ SampleRate: 1, }, + SendMetadata: true, }, cfg, "failed to create default config") assert.NoError(t, configcheck.ValidateConfig(cfg)) @@ -101,6 +102,7 @@ func TestLoadConfig(t *testing.T) { Endpoint: "https://trace.agent.datadoghq.eu", }, }, + SendMetadata: true, }, apiConfig) invalidConfig2 := cfg.Exporters["datadog/invalid"].(*config.Config) @@ -128,6 +130,7 @@ func TestCreateAPIMetricsExporter(t *testing.T) { // Use the mock server for API key validation c := (cfg.Exporters["datadog/api"]).(*config.Config) c.Metrics.TCPAddr.Endpoint = server.URL + c.SendMetadata = false cfg.Exporters["datadog/api"] = c ctx := context.Background() @@ -160,7 +163,7 @@ func TestCreateAPITracesExporter(t *testing.T) { // Use the mock server for API key validation c := (cfg.Exporters["datadog/api"]).(*config.Config) c.Metrics.TCPAddr.Endpoint = server.URL - cfg.Exporters["datadog/api"] = c + c.SendMetadata = false ctx := context.Background() exp, err := factory.CreateTraceExporter( diff --git a/exporter/datadogexporter/go.mod b/exporter/datadogexporter/go.mod index efacec8394e3..9a9f19ca2df6 100644 --- a/exporter/datadogexporter/go.mod +++ b/exporter/datadogexporter/go.mod @@ -3,6 +3,7 @@ module github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datado go 1.14 require ( + github.com/aws/aws-sdk-go v1.34.9 github.com/DataDog/datadog-agent/pkg/trace/exportable v0.0.0-20201016145401-4646cf596b02 github.com/gogo/protobuf v1.3.1 github.com/patrickmn/go-cache v2.1.0+incompatible diff --git a/exporter/datadogexporter/metadata/ec2/ec2.go b/exporter/datadogexporter/metadata/ec2/ec2.go new file mode 100644 index 000000000000..c24f7ac97c6d --- /dev/null +++ b/exporter/datadogexporter/metadata/ec2/ec2.go @@ -0,0 +1,80 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package ec2 + +import ( + "strings" + + "github.com/aws/aws-sdk-go/aws/ec2metadata" + "github.com/aws/aws-sdk-go/aws/session" + "go.uber.org/zap" +) + +var defaultPrefixes = [3]string{"ip-", "domu", "ec2amaz-"} + +type HostInfo struct { + InstanceID string + EC2Hostname string +} + +// isDefaultHostname checks if a hostname is an EC2 default +func isDefaultHostname(hostname string) bool { + for _, val := range defaultPrefixes { + if strings.HasPrefix(hostname, val) { + return true + } + } + + return false +} + +// GetHostInfo gets the hostname info from EC2 metadata +func GetHostInfo(logger *zap.Logger) (hostInfo *HostInfo) { + sess, err := session.NewSession() + hostInfo = &HostInfo{} + + if err != nil { + logger.Warn("Failed to build AWS session", zap.Error(err)) + return + } + + meta := ec2metadata.New(sess) + + if !meta.Available() { + logger.Info("EC2 Metadata not available") + return + } + + if idDoc, err := meta.GetInstanceIdentityDocument(); err == nil { + hostInfo.InstanceID = idDoc.InstanceID + } else { + logger.Warn("Failed to get EC2 instance id document", zap.Error(err)) + } + + if ec2Hostname, err := meta.GetMetadata("hostname"); err == nil { + hostInfo.EC2Hostname = ec2Hostname + } else { + logger.Warn("Failed to get EC2 hostname", zap.Error(err)) + } + + return +} + +func (hi *HostInfo) GetHostname(logger *zap.Logger) string { + if isDefaultHostname(hi.EC2Hostname) { + return hi.InstanceID + } + + return hi.EC2Hostname +} diff --git a/exporter/datadogexporter/metadata/ec2/ec2_test.go b/exporter/datadogexporter/metadata/ec2/ec2_test.go new file mode 100644 index 000000000000..3f9f7c5add90 --- /dev/null +++ b/exporter/datadogexporter/metadata/ec2/ec2_test.go @@ -0,0 +1,52 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package ec2 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +const ( + testIP = "ip-12-34-56-78.us-west-2.compute.internal" + testDomu = "domu-12-34-56-78.us-west-2.compute.internal" + testEC2 = "ec2amaz-12-34-56-78.us-west-2.compute.internal" + customHost = "custom-hostname" + testInstanceID = "i-0123456789" +) + +func TestDefaultHostname(t *testing.T) { + assert.True(t, isDefaultHostname(testIP)) + assert.True(t, isDefaultHostname(testDomu)) + assert.True(t, isDefaultHostname(testEC2)) + assert.False(t, isDefaultHostname(customHost)) +} + +func TestGetHostname(t *testing.T) { + logger := zap.NewNop() + + hostInfo := &HostInfo{ + InstanceID: testInstanceID, + EC2Hostname: testIP, + } + assert.Equal(t, testInstanceID, hostInfo.GetHostname(logger)) + + hostInfo = &HostInfo{ + InstanceID: testInstanceID, + EC2Hostname: customHost, + } + assert.Equal(t, customHost, hostInfo.GetHostname(logger)) +} diff --git a/exporter/datadogexporter/metadata/host.go b/exporter/datadogexporter/metadata/host.go index 3ceca68e0d6a..faa1979b71ce 100644 --- a/exporter/datadogexporter/metadata/host.go +++ b/exporter/datadogexporter/metadata/host.go @@ -18,14 +18,18 @@ import ( "go.uber.org/zap" "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datadogexporter/config" + "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datadogexporter/metadata/ec2" "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datadogexporter/metadata/system" "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datadogexporter/metadata/valid" "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datadogexporter/utils/cache" ) // GetHost gets the hostname according to configuration. -// It gets the configuration hostname and if -// not available it relies on the OS hostname +// It checks in the following order +// 1. Cache +// 2. Configuration +// 3. EC2 instance metadata +// 4. System func GetHost(logger *zap.Logger, cfg *config.Config) *string { if cacheVal, ok := cache.Cache.Get(cache.CanonicalHostnameKey); ok { return cacheVal.(*string) @@ -38,9 +42,14 @@ func GetHost(logger *zap.Logger, cfg *config.Config) *string { logger.Error("Hostname set in configuration is invalid", zap.Error(err)) } - // Get system hostname - hostInfo := system.GetHostInfo(logger) - hostname := hostInfo.GetHostname(logger) + ec2Info := ec2.GetHostInfo(logger) + hostname := ec2Info.GetHostname(logger) + + if hostname == "" { + // Get system hostname + systemInfo := system.GetHostInfo(logger) + hostname = systemInfo.GetHostname(logger) + } if err := valid.Hostname(hostname); err != nil { // If invalid log but continue diff --git a/exporter/datadogexporter/metadata/metadata.go b/exporter/datadogexporter/metadata/metadata.go new file mode 100644 index 000000000000..ab35130bcd3f --- /dev/null +++ b/exporter/datadogexporter/metadata/metadata.go @@ -0,0 +1,169 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package metadata + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "go.opentelemetry.io/collector/component" + "go.uber.org/zap" + + "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datadogexporter/config" + "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datadogexporter/metadata/ec2" + "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datadogexporter/metadata/system" + "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datadogexporter/utils" +) + +// HostMetadata includes metadata about the host tags, +// host aliases and identifies the host as an OpenTelemetry host +type HostMetadata struct { + // Meta includes metadata about the host. + Meta *Meta `json:"meta"` + + // InternalHostname is the canonical hostname + InternalHostname string `json:"internalHostname"` + + // Version is the OpenTelemetry Collector version. + // This is used for correctly identifying the Collector in the backend, + // and for telemetry purposes. + Version string `json:"otel_version"` + + // Flavor is always set to "opentelemetry-collector". + // It is used for telemetry purposes in the backend. + Flavor string `json:"agent-flavor"` + + // Tags includes the host tags + Tags *HostTags `json:"host-tags"` +} + +// HostTags are the host tags. +// Currently only system (configuration) tags are considered. +type HostTags struct { + // OTel are host tags set in the configuration + OTel []string `json:"otel,omitempty"` +} + +// Meta includes metadata about the host aliases +type Meta struct { + // InstanceID is the EC2 instance id the Collector is running on, if available + InstanceID string `json:"instance-id,omitempty"` + + // EC2Hostname is the hostname from the EC2 metadata API + EC2Hostname string `json:"ec2-hostname,omitempty"` + + // Hostname is the canonical hostname + Hostname string `json:"hostname"` + + // SocketHostname is the OS hostname + SocketHostname string `json:"socket-hostname,omitempty"` + + // SocketFqdn is the FQDN hostname + SocketFqdn string `json:"socket-fqdn,omitempty"` + + // HostAliases are other available host names + HostAliases []string `json:"host-aliases,omitempty"` +} + +func getHostMetadata(params component.ExporterCreateParams, cfg *config.Config) *HostMetadata { + hostname := *GetHost(params.Logger, cfg) + tags := cfg.GetHostTags() + + ec2HostInfo := ec2.GetHostInfo(params.Logger) + systemHostInfo := system.GetHostInfo(params.Logger) + + return &HostMetadata{ + InternalHostname: hostname, + Flavor: params.ApplicationStartInfo.ExeName, + Version: params.ApplicationStartInfo.Version, + Tags: &HostTags{tags}, + Meta: &Meta{ + InstanceID: ec2HostInfo.InstanceID, + EC2Hostname: ec2HostInfo.EC2Hostname, + Hostname: hostname, + SocketHostname: systemHostInfo.OS, + SocketFqdn: systemHostInfo.FQDN, + }, + } +} + +func pushMetadata(cfg *config.Config, startInfo component.ApplicationStartInfo, metadata *HostMetadata) error { + path := cfg.Metrics.TCPAddr.Endpoint + "/intake" + buf, _ := json.Marshal(metadata) + req, _ := http.NewRequest(http.MethodPost, path, bytes.NewBuffer(buf)) + utils.SetDDHeaders(req.Header, startInfo, cfg.API.Key) + utils.SetExtraHeaders(req.Header, utils.JSONHeaders) + client := utils.NewHTTPClient(10 * time.Second) + resp, err := client.Do(req) + + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf( + "'%d - %s' error when sending metadata payload to %s", + resp.StatusCode, + resp.Status, + path, + ) + } + + return nil +} + +func getAndPushMetadata(params component.ExporterCreateParams, cfg *config.Config) { + const maxRetries = 5 + hostMetadata := getHostMetadata(params, cfg) + + params.Logger.Debug("Sending host metadata payload", zap.Any("payload", hostMetadata)) + + numRetries, err := utils.DoWithRetries(maxRetries, func() error { + return pushMetadata(cfg, params.ApplicationStartInfo, hostMetadata) + }) + + if err != nil { + params.Logger.Warn("Sending host metadata failed", zap.Error(err)) + } else { + params.Logger.Info("Sent host metadata", zap.Int("retries", numRetries)) + } + +} + +// Pusher pushes host metadata payloads periodically to Datadog intake +func Pusher(ctx context.Context, params component.ExporterCreateParams, cfg *config.Config) { + // Push metadata every 30 minutes + ticker := time.NewTicker(30 * time.Minute) + defer ticker.Stop() + defer params.Logger.Debug("Shut down host metadata routine") + + // Run one first time at startup + getAndPushMetadata(params, cfg) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: // Send host metadata + getAndPushMetadata(params, cfg) + } + } +} diff --git a/exporter/datadogexporter/metadata/metadata_test.go b/exporter/datadogexporter/metadata/metadata_test.go new file mode 100644 index 000000000000..2f210dd12a27 --- /dev/null +++ b/exporter/datadogexporter/metadata/metadata_test.go @@ -0,0 +1,101 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package metadata + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/component" + "go.uber.org/zap" + + "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datadogexporter/config" + "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datadogexporter/utils/cache" +) + +func TestGetHostMetadata(t *testing.T) { + cache.Cache.Flush() + params := component.ExporterCreateParams{ + Logger: zap.NewNop(), + ApplicationStartInfo: component.ApplicationStartInfo{ + ExeName: "otelcontribcol", + Version: "1.0", + }, + } + + cfg := &config.Config{TagsConfig: config.TagsConfig{ + Hostname: "hostname", + Env: "prod", + Tags: []string{"key1:tag1", "key2:tag2"}, + }} + + metadata := getHostMetadata(params, cfg) + + assert.Equal(t, metadata.InternalHostname, "hostname") + assert.Equal(t, metadata.Flavor, "otelcontribcol") + assert.Equal(t, metadata.Version, "1.0") + assert.Equal(t, metadata.Meta.Hostname, "hostname") + assert.ElementsMatch(t, metadata.Tags.OTel, []string{"key1:tag1", "key2:tag2", "env:prod"}) +} + +func TestPushMetadata(t *testing.T) { + + cfg := &config.Config{API: config.APIConfig{Key: "apikey"}} + + startInfo := component.ApplicationStartInfo{ + ExeName: "otelcontribcol", + Version: "1.0", + } + + metadata := HostMetadata{ + InternalHostname: "hostname", + Flavor: "otelcontribcol", + Version: "1.0", + Tags: &HostTags{OTel: []string{"key1:val1"}}, + Meta: &Meta{ + InstanceID: "i-XXXXXXXXXX", + EC2Hostname: "ip-123-45-67-89", + Hostname: "hostname", + SocketHostname: "ip-123-45-67-89", + SocketFqdn: "ip-123-45-67-89.internal", + }, + } + + handler := http.NewServeMux() + handler.HandleFunc("/intake", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("DD-Api-Key"), "apikey") + assert.Equal(t, r.Header.Get("User-Agent"), "otelcontribcol/1.0") + + body, err := ioutil.ReadAll(r.Body) + require.NoError(t, err) + + var recvMetadata HostMetadata + err = json.Unmarshal(body, &recvMetadata) + require.NoError(t, err) + assert.Equal(t, metadata, recvMetadata) + }) + + ts := httptest.NewServer(handler) + defer ts.Close() + cfg.Metrics.Endpoint = ts.URL + + err := pushMetadata(cfg, startInfo, &metadata) + require.NoError(t, err) +} diff --git a/exporter/datadogexporter/metrics/utils.go b/exporter/datadogexporter/metrics/utils.go index 18465ad24a3d..452991e02341 100644 --- a/exporter/datadogexporter/metrics/utils.go +++ b/exporter/datadogexporter/metrics/utils.go @@ -68,13 +68,6 @@ func AddHostname(metrics []datadog.Metric, logger *zap.Logger, cfg *config.Confi } } -// AddTags adds the given tags to all metrics in the provided list -func AddTags(metrics []datadog.Metric, tags []string) { - for i := range metrics { - metrics[i].Tags = append(metrics[i].Tags, tags...) - } -} - // AddNamespace prepends all metric names with a given namespace func AddNamespace(metrics []datadog.Metric, namespace string) { for i := range metrics { diff --git a/exporter/datadogexporter/metrics/utils_test.go b/exporter/datadogexporter/metrics/utils_test.go index 0167913da5c6..c78df206205f 100644 --- a/exporter/datadogexporter/metrics/utils_test.go +++ b/exporter/datadogexporter/metrics/utils_test.go @@ -133,15 +133,3 @@ func TestAddNamespace(t *testing.T) { assert.Equal(t, "namespace.test.metric", *ms[0].Metric) assert.Equal(t, "namespace.test.metric2", *ms[1].Metric) } - -func TestAddTags(t *testing.T) { - ms := []datadog.Metric{ - NewGauge("test.metric", 0, 1.0, []string{}), - NewGauge("test.metric2", 0, 2.0, []string{"tag:value"}), - } - - AddTags(ms, []string{"othertag:othervalue"}) - - assert.Equal(t, []string{"othertag:othervalue"}, ms[0].Tags) - assert.Equal(t, []string{"tag:value", "othertag:othervalue"}, ms[1].Tags) -} diff --git a/exporter/datadogexporter/metrics_exporter.go b/exporter/datadogexporter/metrics_exporter.go index 5caf3049928d..a0139582f7bb 100644 --- a/exporter/datadogexporter/metrics_exporter.go +++ b/exporter/datadogexporter/metrics_exporter.go @@ -18,6 +18,7 @@ import ( "context" "time" + "go.opentelemetry.io/collector/component" "go.opentelemetry.io/collector/consumer/pdata" "go.uber.org/zap" "gopkg.in/zorkian/go-datadog-api.v2" @@ -31,17 +32,16 @@ type metricsExporter struct { logger *zap.Logger cfg *config.Config client *datadog.Client - tags []string } -func newMetricsExporter(logger *zap.Logger, cfg *config.Config) (*metricsExporter, error) { +func newMetricsExporter(params component.ExporterCreateParams, cfg *config.Config) (*metricsExporter, error) { client := utils.CreateClient(cfg.API.Key, cfg.Metrics.TCPAddr.Endpoint) - utils.ValidateAPIKey(logger, client) + client.ExtraHeader["User-Agent"] = utils.UserAgent(params.ApplicationStartInfo) + client.HttpClient = utils.NewHTTPClient(10 * time.Second) - // Calculate tags at startup - tags := cfg.TagsConfig.GetTags(false) + utils.ValidateAPIKey(params.Logger, client) - return &metricsExporter{logger, cfg, client, tags}, nil + return &metricsExporter{params.Logger, cfg, client}, nil } func (exp *metricsExporter) processMetrics(ms []datadog.Metric) { @@ -50,9 +50,8 @@ func (exp *metricsExporter) processMetrics(ms []datadog.Metric) { if addNamespace { metrics.AddNamespace(ms, exp.cfg.Metrics.Namespace) } - metrics.AddHostname(ms, exp.logger, exp.cfg) - metrics.AddTags(ms, exp.tags) + metrics.AddHostname(ms, exp.logger, exp.cfg) } func (exp *metricsExporter) PushMetricsData(ctx context.Context, md pdata.Metrics) (int, error) { diff --git a/exporter/datadogexporter/metrics_exporter_test.go b/exporter/datadogexporter/metrics_exporter_test.go index c0ae0dc58fdc..4822561e8f00 100644 --- a/exporter/datadogexporter/metrics_exporter_test.go +++ b/exporter/datadogexporter/metrics_exporter_test.go @@ -19,6 +19,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/component" "go.opentelemetry.io/collector/config/confignet" "go.uber.org/zap" "gopkg.in/zorkian/go-datadog-api.v2" @@ -44,10 +45,10 @@ func TestNewExporter(t *testing.T) { } cfg.Sanitize() - logger := zap.NewNop() + params := component.ExporterCreateParams{Logger: zap.NewNop()} // The client should have been created correctly - exp, err := newMetricsExporter(logger, cfg) + exp, err := newMetricsExporter(params, cfg) require.NoError(t, err) assert.NotNil(t, exp) } @@ -60,6 +61,7 @@ func TestProcessMetrics(t *testing.T) { API: config.APIConfig{ Key: "ddog_32_characters_long_api_key1", }, + // Global tags should be ignored and sent as metadata TagsConfig: config.TagsConfig{ Hostname: "test-host", Env: "test_env", @@ -74,9 +76,8 @@ func TestProcessMetrics(t *testing.T) { } cfg.Sanitize() - logger := zap.NewNop() - - exp, err := newMetricsExporter(logger, cfg) + params := component.ExporterCreateParams{Logger: zap.NewNop()} + exp, err := newMetricsExporter(params, cfg) require.NoError(t, err) @@ -94,7 +95,7 @@ func TestProcessMetrics(t *testing.T) { assert.Equal(t, "test-host", *ms[0].Host) assert.Equal(t, "test.metric_name", *ms[0].Metric) assert.ElementsMatch(t, - []string{"key:val", "env:test_env", "key2:val2"}, + []string{"key2:val2"}, ms[0].Tags, ) diff --git a/exporter/datadogexporter/trace_connection.go b/exporter/datadogexporter/trace_connection.go index 1a530a14dbfd..d2a346582fae 100644 --- a/exporter/datadogexporter/trace_connection.go +++ b/exporter/datadogexporter/trace_connection.go @@ -24,6 +24,7 @@ import ( "github.com/DataDog/datadog-agent/pkg/trace/exportable/pb" "github.com/DataDog/datadog-agent/pkg/trace/exportable/stats" "github.com/gogo/protobuf/proto" + "go.opentelemetry.io/collector/component" "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datadogexporter/utils" ) @@ -38,6 +39,7 @@ type traceEdgeConnection struct { traceURL string statsURL string apiKey string + startInfo component.ApplicationStartInfo InsecureSkipVerify bool } @@ -47,12 +49,13 @@ const ( ) // CreateTraceEdgeConnection returns a new TraceEdgeConnection -func CreateTraceEdgeConnection(rootURL, apiKey string) TraceEdgeConnection { +func CreateTraceEdgeConnection(rootURL, apiKey string, startInfo component.ApplicationStartInfo) TraceEdgeConnection { return &traceEdgeConnection{ - traceURL: rootURL + "/api/v0.2/traces", - statsURL: rootURL + "/api/v0.2/stats", - apiKey: apiKey, + traceURL: rootURL + "/api/v0.2/traces", + statsURL: rootURL + "/api/v0.2/stats", + startInfo: startInfo, + apiKey: apiKey, } } @@ -141,6 +144,7 @@ func (con *traceEdgeConnection) SendStats(ctx context.Context, sts *stats.Payloa // sendPayloadToTraceEdge sends a payload to Trace Edge func (con *traceEdgeConnection) sendPayloadToTraceEdge(ctx context.Context, apiKey string, payload *Payload, url string) (bool, error) { + // Create the request to be sent to the API req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload.Bytes)) @@ -148,7 +152,7 @@ func (con *traceEdgeConnection) sendPayloadToTraceEdge(ctx context.Context, apiK return false, err } - utils.SetDDHeaders(req.Header, apiKey) + utils.SetDDHeaders(req.Header, con.startInfo, apiKey) utils.SetExtraHeaders(req.Header, payload.Headers) client := utils.NewHTTPClient(traceEdgeTimeout) diff --git a/exporter/datadogexporter/traces_exporter.go b/exporter/datadogexporter/traces_exporter.go index 8ebee9562e7a..e7d3cad5fae1 100644 --- a/exporter/datadogexporter/traces_exporter.go +++ b/exporter/datadogexporter/traces_exporter.go @@ -21,6 +21,7 @@ import ( "github.com/DataDog/datadog-agent/pkg/trace/exportable/config/configdefs" "github.com/DataDog/datadog-agent/pkg/trace/exportable/obfuscate" "github.com/DataDog/datadog-agent/pkg/trace/exportable/pb" + "go.opentelemetry.io/collector/component" "go.opentelemetry.io/collector/consumer/pdata" "go.uber.org/zap" "gopkg.in/zorkian/go-datadog-api.v2" @@ -36,7 +37,6 @@ type traceExporter struct { edgeConnection TraceEdgeConnection obfuscator *obfuscate.Obfuscator client *datadog.Client - tags []string } var ( @@ -57,24 +57,21 @@ var ( } ) -func newTraceExporter(logger *zap.Logger, cfg *config.Config) (*traceExporter, error) { +func newTraceExporter(params component.ExporterCreateParams, cfg *config.Config) (*traceExporter, error) { // client to send running metric to the backend & perform API key validation client := utils.CreateClient(cfg.API.Key, cfg.Metrics.TCPAddr.Endpoint) - utils.ValidateAPIKey(logger, client) + utils.ValidateAPIKey(params.Logger, client) // removes potentially sensitive info and PII, approach taken from serverless approach // https://github.com/DataDog/datadog-serverless-functions/blob/11f170eac105d66be30f18eda09eca791bc0d31b/aws/logs_monitoring/trace_forwarder/cmd/trace/main.go#L43 obfuscator := obfuscate.NewObfuscator(obfuscatorConfig) - // Calculate tags at startup - tags := cfg.TagsConfig.GetTags(false) exporter := &traceExporter{ - logger: logger, + logger: params.Logger, cfg: cfg, - edgeConnection: CreateTraceEdgeConnection(cfg.Traces.TCPAddr.Endpoint, cfg.API.Key), + edgeConnection: CreateTraceEdgeConnection(cfg.Traces.TCPAddr.Endpoint, cfg.API.Key, params.ApplicationStartInfo), obfuscator: obfuscator, client: client, - tags: tags, } return exporter, nil @@ -99,7 +96,7 @@ func (exp *traceExporter) pushTraceData( // convert traces to datadog traces and group trace payloads by env // we largely apply the same logic as the serverless implementation, simplified a bit // https://github.com/DataDog/datadog-serverless-functions/blob/f5c3aedfec5ba223b11b76a4239fcbf35ec7d045/aws/logs_monitoring/trace_forwarder/cmd/trace/main.go#L61-L83 - ddTraces, err := ConvertToDatadogTd(td, exp.cfg, exp.tags) + ddTraces, err := ConvertToDatadogTd(td, exp.cfg) if err != nil { exp.logger.Info("failed to convert traces", zap.Error(err)) diff --git a/exporter/datadogexporter/traces_exporter_test.go b/exporter/datadogexporter/traces_exporter_test.go index 20d19473e7e3..9cbb5b87ba98 100644 --- a/exporter/datadogexporter/traces_exporter_test.go +++ b/exporter/datadogexporter/traces_exporter_test.go @@ -157,10 +157,10 @@ func TestNewTraceExporter(t *testing.T) { cfg := &config.Config{} cfg.API.Key = "ddog_32_characters_long_api_key1" cfg.Metrics.TCPAddr.Endpoint = metricsServer.URL - logger := zap.NewNop() + params := component.ExporterCreateParams{Logger: zap.NewNop()} // The client should have been created correctly - exp, err := newTraceExporter(logger, cfg) + exp, err := newTraceExporter(params, cfg) assert.NoError(t, err) assert.NotNil(t, exp) } @@ -196,9 +196,9 @@ func TestPushTraceData(t *testing.T) { }, }, } - logger := zap.NewNop() - exp, err := newTraceExporter(logger, cfg) + params := component.ExporterCreateParams{Logger: zap.NewNop()} + exp, err := newTraceExporter(params, cfg) assert.NoError(t, err) diff --git a/exporter/datadogexporter/translate_traces.go b/exporter/datadogexporter/translate_traces.go index 09245cbf0d93..b9e69cafce4f 100644 --- a/exporter/datadogexporter/translate_traces.go +++ b/exporter/datadogexporter/translate_traces.go @@ -19,7 +19,6 @@ import ( "fmt" "net/http" "strconv" - "strings" "github.com/DataDog/datadog-agent/pkg/trace/exportable/pb" "go.opencensus.io/trace" @@ -70,7 +69,7 @@ var statusCodes = map[int32]codeDetails{ } // converts Traces into an array of datadog trace payloads grouped by env -func ConvertToDatadogTd(td pdata.Traces, cfg *config.Config, globalTags []string) ([]*pb.TracePayload, error) { +func ConvertToDatadogTd(td pdata.Traces, cfg *config.Config) ([]*pb.TracePayload, error) { // get hostname tag // this is getting abstracted out to config // TODO pass logger here once traces code stabilizes @@ -91,7 +90,7 @@ func ConvertToDatadogTd(td pdata.Traces, cfg *config.Config, globalTags []string } // TODO: Also pass in globalTags here when we know what to do with them - payload, err := resourceSpansToDatadogSpans(rs, hostname, cfg, globalTags) + payload, err := resourceSpansToDatadogSpans(rs, hostname, cfg) if err != nil { return traces, err } @@ -130,7 +129,7 @@ func AggregateTracePayloadsByEnv(tracePayloads []*pb.TracePayload) []*pb.TracePa } // converts a Trace's resource spans into a trace payload -func resourceSpansToDatadogSpans(rs pdata.ResourceSpans, hostname string, cfg *config.Config, globalTags []string) (pb.TracePayload, error) { +func resourceSpansToDatadogSpans(rs pdata.ResourceSpans, hostname string, cfg *config.Config) (pb.TracePayload, error) { // get env tag env := cfg.Env @@ -166,7 +165,7 @@ func resourceSpansToDatadogSpans(rs pdata.ResourceSpans, hostname string, cfg *c extractInstrumentationLibraryTags(ils.InstrumentationLibrary(), datadogTags) spans := ils.Spans() for j := 0; j < spans.Len(); j++ { - span, err := spanToDatadogSpan(spans.At(j), resourceServiceName, datadogTags, cfg, globalTags) + span, err := spanToDatadogSpan(spans.At(j), resourceServiceName, datadogTags, cfg) if err != nil { return payload, err @@ -213,7 +212,6 @@ func spanToDatadogSpan(s pdata.Span, serviceName string, datadogTags map[string]string, cfg *config.Config, - globalTags []string, ) (*pb.Span, error) { // otel specification resource service.name takes precedence // and configuration DD_ENV as fallback if it exists @@ -300,16 +298,6 @@ func spanToDatadogSpan(s pdata.Span, setStringTag(span, key, val) } - for _, val := range globalTags { - parts := strings.Split(val, ":") - // only apply global tag if its not service/env/version/host and it is not malformed - if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" || isCanonicalSpanTag(strings.TrimSpace(parts[0])) { - continue - } - - setStringTag(span, strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])) - } - return span, nil } @@ -485,16 +473,3 @@ func getDatadogResourceName(s pdata.Span, datadogTags map[string]string) string return s.Name() } - -// we want to handle these tags separately -func isCanonicalSpanTag(category string) bool { - switch category { - case - "env", - "host", - "service", - "version": - return true - } - return false -} diff --git a/exporter/datadogexporter/translate_traces_test.go b/exporter/datadogexporter/translate_traces_test.go index c26abe9eb4c5..72e830971a8d 100644 --- a/exporter/datadogexporter/translate_traces_test.go +++ b/exporter/datadogexporter/translate_traces_test.go @@ -125,7 +125,7 @@ func TestConvertToDatadogTd(t *testing.T) { traces := pdata.NewTraces() traces.ResourceSpans().Resize(1) - outputTraces, err := ConvertToDatadogTd(traces, &config.Config{}, []string{}) + outputTraces, err := ConvertToDatadogTd(traces, &config.Config{}) assert.NoError(t, err) assert.Equal(t, 1, len(outputTraces)) @@ -134,7 +134,7 @@ func TestConvertToDatadogTd(t *testing.T) { func TestConvertToDatadogTdNoResourceSpans(t *testing.T) { traces := pdata.NewTraces() - outputTraces, err := ConvertToDatadogTd(traces, &config.Config{}, []string{}) + outputTraces, err := ConvertToDatadogTd(traces, &config.Config{}) assert.NoError(t, err) assert.Equal(t, 0, len(outputTraces)) @@ -171,7 +171,7 @@ func TestObfuscation(t *testing.T) { ilss.Spans().Resize(1) span.CopyTo(ilss.Spans().At(0)) - outputTraces, err := ConvertToDatadogTd(traces, &config.Config{}, []string{}) + outputTraces, err := ConvertToDatadogTd(traces, &config.Config{}) assert.NoError(t, err) @@ -196,9 +196,8 @@ func TestBasicTracesTranslation(t *testing.T) { // set shouldError and resourceServiceandEnv to false to test defaut behavior rs := NewResourceSpansData(mockTraceID, mockSpanID, mockParentSpanID, false, false) - mockGlobalTags := []string{"global_key:global_value"} // translate mocks to datadog traces - datadogPayload, err := resourceSpansToDatadogSpans(rs, hostname, &config.Config{}, mockGlobalTags) + datadogPayload, err := resourceSpansToDatadogSpans(rs, hostname, &config.Config{}) if err != nil { t.Fatalf("Failed to convert from pdata ResourceSpans to pb.TracePayload: %v", err) @@ -234,7 +233,7 @@ func TestBasicTracesTranslation(t *testing.T) { assert.Equal(t, "web", datadogPayload.Traces[0].Spans[0].Type) // ensure that span.meta and span.metrics pick up attibutes, instrumentation ibrary and resource attribs - assert.Equal(t, 10, len(datadogPayload.Traces[0].Spans[0].Meta)) + assert.Equal(t, 9, len(datadogPayload.Traces[0].Spans[0].Meta)) assert.Equal(t, 1, len(datadogPayload.Traces[0].Spans[0].Metrics)) // ensure that span error is based on otlp span status @@ -264,7 +263,7 @@ func TestTracesTranslationErrorsAndResource(t *testing.T) { rs := NewResourceSpansData(mockTraceID, mockSpanID, mockParentSpanID, true, true) // translate mocks to datadog traces - datadogPayload, err := resourceSpansToDatadogSpans(rs, hostname, &config.Config{}, []string{}) + datadogPayload, err := resourceSpansToDatadogSpans(rs, hostname, &config.Config{}) if err != nil { t.Fatalf("Failed to convert from pdata ResourceSpans to pb.TracePayload: %v", err) @@ -309,7 +308,7 @@ func TestTracesTranslationConfig(t *testing.T) { } // translate mocks to datadog traces - datadogPayload, err := resourceSpansToDatadogSpans(rs, hostname, &cfg, []string{"other_tag:example", "invalidthings"}) + datadogPayload, err := resourceSpansToDatadogSpans(rs, hostname, &cfg) if err != nil { t.Fatalf("Failed to convert from pdata ResourceSpans to pb.TracePayload: %v", err) @@ -330,9 +329,8 @@ func TestTracesTranslationConfig(t *testing.T) { // ensure that env gives resource deployment.environment priority assert.Equal(t, "test-env", datadogPayload.Env) - assert.Equal(t, 14, len(datadogPayload.Traces[0].Spans[0].Meta)) + assert.Equal(t, 13, len(datadogPayload.Traces[0].Spans[0].Meta)) assert.Equal(t, "v1", datadogPayload.Traces[0].Spans[0].Meta["version"]) - assert.Equal(t, "example", datadogPayload.Traces[0].Spans[0].Meta["other_tag"]) } // ensure that the translation returns early if no resource instrumentation library spans @@ -350,7 +348,7 @@ func TestTracesTranslationNoIls(t *testing.T) { } // translate mocks to datadog traces - datadogPayload, err := resourceSpansToDatadogSpans(rs, hostname, &cfg, []string{"other_tag:example", "invalidthings"}) + datadogPayload, err := resourceSpansToDatadogSpans(rs, hostname, &cfg) if err != nil { t.Fatalf("Failed to convert from pdata ResourceSpans to pb.TracePayload: %v", err) @@ -468,14 +466,6 @@ func TestHttpResourceTag(t *testing.T) { assert.Equal(t, "POST", resourceName) } -func TestCanonicalSpanTag(t *testing.T) { - baseCase := isCanonicalSpanTag("notenv") - isCanonicalCase := isCanonicalSpanTag("env") - - assert.Equal(t, false, baseCase) - assert.Equal(t, true, isCanonicalCase) -} - // ensure that payloads get aggregated by env to reduce number of flushes func TestTracePayloadAggr(t *testing.T) { diff --git a/exporter/datadogexporter/utils/http.go b/exporter/datadogexporter/utils/http.go index e7ca004ffe9a..24e35d213373 100644 --- a/exporter/datadogexporter/utils/http.go +++ b/exporter/datadogexporter/utils/http.go @@ -20,6 +20,8 @@ import ( "net" "net/http" "time" + + "go.opentelemetry.io/collector/component" ) var ( @@ -58,14 +60,28 @@ func SetExtraHeaders(h http.Header, extras map[string]string) { } } -func SetDDHeaders(reqHeader http.Header, apiKey string) { - // userAgent is the computed user agent we'll use when - // communicating with Datadog - var userAgent = fmt.Sprintf( - "%s/%s/%s (+%s)", - "otel-collector-exporter", "0.1", "1", "http://localhost", - ) +func UserAgent(startInfo component.ApplicationStartInfo) string { + return fmt.Sprintf("%s/%s", startInfo.ExeName, startInfo.Version) +} +// SetDDHeaders sets the Datadog-specific headers +func SetDDHeaders(reqHeader http.Header, startInfo component.ApplicationStartInfo, apiKey string) { reqHeader.Set("DD-Api-Key", apiKey) - reqHeader.Set("User-Agent", userAgent) + reqHeader.Set("User-Agent", UserAgent(startInfo)) +} + +// DoWithRetries repeats a fallible action up to `maxRetries` times +// with exponential backoff +func DoWithRetries(maxRetries int, fn func() error) (i int, err error) { + wait := 1 * time.Second + for i = 0; i < maxRetries; i++ { + err = fn() + if err == nil { + return + } + time.Sleep(wait) + wait = 2 * wait + } + + return } diff --git a/exporter/datadogexporter/utils/http_test.go b/exporter/datadogexporter/utils/http_test.go new file mode 100644 index 000000000000..fc6745a4cb9a --- /dev/null +++ b/exporter/datadogexporter/utils/http_test.go @@ -0,0 +1,55 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package utils + +import ( + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/component" +) + +var ( + startInfo = component.ApplicationStartInfo{ + ExeName: "otelcontribcol", + Version: "1.0", + } +) + +func TestUserAgent(t *testing.T) { + + assert.Equal(t, UserAgent(startInfo), "otelcontribcol/1.0") +} + +func TestDDHeaders(t *testing.T) { + header := http.Header{} + apiKey := "apikey" + SetDDHeaders(header, startInfo, apiKey) + assert.Equal(t, header.Get("DD-Api-Key"), apiKey) + assert.Equal(t, header.Get("USer-Agent"), "otelcontribcol/1.0") + +} + +func TestDoWithRetries(t *testing.T) { + i, err := DoWithRetries(3, func() error { return nil }) + require.NoError(t, err) + assert.Equal(t, i, 0) + + _, err = DoWithRetries(1, func() error { return errors.New("action failed") }) + require.Error(t, err) +}