diff --git a/ddtrace/tracer/option.go b/ddtrace/tracer/option.go index d06e51c8e1..32098ae5ed 100644 --- a/ddtrace/tracer/option.go +++ b/ddtrace/tracer/option.go @@ -33,6 +33,7 @@ import ( "github.com/DataDog/dd-trace-go/v2/internal" appsecconfig "github.com/DataDog/dd-trace-go/v2/internal/appsec/config" "github.com/DataDog/dd-trace-go/v2/internal/civisibility/constants" + internalconfig "github.com/DataDog/dd-trace-go/v2/internal/config" "github.com/DataDog/dd-trace-go/v2/internal/env" "github.com/DataDog/dd-trace-go/v2/internal/globalconfig" llmobsconfig "github.com/DataDog/dd-trace-go/v2/internal/llmobs/config" @@ -373,6 +374,7 @@ const partialFlushMinSpansDefault = 1000 // and passed user opts. func newConfig(opts ...StartOption) (*config, error) { c := new(config) + internalConfig := internalconfig.GlobalConfig() // If this was built with a recent-enough version of Orchestrion, force the orchestrion config to // the baked-in values. We do this early so that opts can be used to override the baked-in values, @@ -475,7 +477,7 @@ func newConfig(opts ...StartOption) (*config, error) { c.logStartup = internal.BoolEnv("DD_TRACE_STARTUP_LOGS", true) c.runtimeMetrics = internal.BoolVal(getDDorOtelConfig("metrics"), false) c.runtimeMetricsV2 = internal.BoolEnv("DD_RUNTIME_METRICS_V2_ENABLED", true) - c.debug = internal.BoolVal(getDDorOtelConfig("debugMode"), false) + c.debug = internalConfig.IsDebugEnabled() c.logDirectory = env.Get("DD_TRACE_LOG_DIRECTORY") c.enabled = newDynamicConfig("tracing_enabled", internal.BoolVal(getDDorOtelConfig("enabled"), true), func(_ bool) bool { return true }, equal[bool]) if _, ok := env.Lookup("DD_TRACE_ENABLED"); ok { @@ -622,7 +624,6 @@ func newConfig(opts ...StartOption) (*config, error) { if c.debug { log.SetLevel(log.LevelDebug) } - // Check if CI Visibility mode is enabled if internal.BoolEnv(constants.CIVisibilityEnabledEnvironmentVariable, false) { c.ciVisibilityEnabled = true // Enable CI Visibility mode diff --git a/ddtrace/tracer/otel_dd_mappings.go b/ddtrace/tracer/otel_dd_mappings.go index 9d4e5c5a00..818ece81e0 100644 --- a/ddtrace/tracer/otel_dd_mappings.go +++ b/ddtrace/tracer/otel_dd_mappings.go @@ -93,7 +93,8 @@ var propagationMapping = map[string]string{ func getDDorOtelConfig(configName string) string { config, ok := otelDDConfigs[configName] if !ok { - panic(fmt.Sprintf("Programming Error: %v not found in supported configurations", configName)) + log.Debug("Programming Error: %v not found in supported configurations", configName) + return "" } // 1. Check managed stable config if handsOff diff --git a/ddtrace/tracer/otel_dd_mappings_test.go b/ddtrace/tracer/otel_dd_mappings_test.go index f03381e05d..920ed597ef 100644 --- a/ddtrace/tracer/otel_dd_mappings_test.go +++ b/ddtrace/tracer/otel_dd_mappings_test.go @@ -15,8 +15,9 @@ import ( ) func TestAssessSource(t *testing.T) { + // regression test t.Run("invalid", func(t *testing.T) { - assert.Panics(t, func() { getDDorOtelConfig("invalid") }, "invalid config should panic") + assert.NotPanics(t, func() { getDDorOtelConfig("invalid") }, "invalid config should not panic") }) t.Run("dd", func(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000000..de44b45928 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,133 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025 Datadog, Inc. + +package config + +import ( + "net/url" + "sync" + "testing" + "time" +) + +var ( + globalConfig *Config + configOnce sync.Once +) + +// Config represents global configuration properties. +type Config struct { + // agentURL is the URL of the Datadog agent. + agentURL *url.URL + + // debug enables debug logging. + debug bool + + logStartup bool + + serviceName string + + version string + + env string + + serviceMappings map[string]string + + hostname string + + runtimeMetrics bool + + runtimeMetricsV2 bool + + profilerHotspots bool + + profilerEndpoints bool + + spanAttributeSchemaVersion int + + peerServiceDefaultsEnabled bool + + peerServiceMappings map[string]string + + debugAbandonedSpans bool + + spanTimeout time.Duration + + partialFlushMinSpans int + + partialFlushEnabled bool + + statsComputationEnabled bool + + dataStreamsMonitoringEnabled bool + + dynamicInstrumentationEnabled bool + + globalSampleRate float64 + + ciVisibilityEnabled bool + + ciVisibilityAgentless bool + + logDirectory string + + traceRateLimitPerSecond float64 +} + +// loadConfig initializes and returns a new Config by reading from all configured sources. +// This function is NOT thread-safe and should only be called once through GlobalConfig's sync.Once. +func loadConfig() *Config { + cfg := new(Config) + + // TODO: Use defaults from config json instead of hardcoding them here + cfg.agentURL = provider.getURL("DD_TRACE_AGENT_URL", &url.URL{Scheme: "http", Host: "localhost:8126"}) + cfg.debug = provider.getBool("DD_TRACE_DEBUG", false) + cfg.logStartup = provider.getBool("DD_TRACE_STARTUP_LOGS", false) + cfg.serviceName = provider.getString("DD_SERVICE", "") + cfg.version = provider.getString("DD_VERSION", "") + cfg.env = provider.getString("DD_ENV", "") + cfg.serviceMappings = provider.getMap("DD_SERVICE_MAPPING", nil) + cfg.hostname = provider.getString("DD_TRACE_SOURCE_HOSTNAME", "") + cfg.runtimeMetrics = provider.getBool("DD_RUNTIME_METRICS_ENABLED", false) + cfg.runtimeMetricsV2 = provider.getBool("DD_RUNTIME_METRICS_V2_ENABLED", false) + cfg.profilerHotspots = provider.getBool("DD_PROFILING_CODE_HOTSPOTS_COLLECTION_ENABLED", false) + cfg.profilerEndpoints = provider.getBool("DD_PROFILING_ENDPOINT_COLLECTION_ENABLED", false) + cfg.spanAttributeSchemaVersion = provider.getInt("DD_TRACE_SPAN_ATTRIBUTE_SCHEMA", 0) + cfg.peerServiceDefaultsEnabled = provider.getBool("DD_TRACE_PEER_SERVICE_DEFAULTS_ENABLED", false) + cfg.peerServiceMappings = provider.getMap("DD_TRACE_PEER_SERVICE_MAPPING", nil) + cfg.debugAbandonedSpans = provider.getBool("DD_TRACE_DEBUG_ABANDONED_SPANS", false) + cfg.spanTimeout = provider.getDuration("DD_TRACE_ABANDONED_SPAN_TIMEOUT", 0) + cfg.partialFlushMinSpans = provider.getInt("DD_TRACE_PARTIAL_FLUSH_MIN_SPANS", 0) + cfg.partialFlushEnabled = provider.getBool("DD_TRACE_PARTIAL_FLUSH_ENABLED", false) + cfg.statsComputationEnabled = provider.getBool("DD_TRACE_STATS_COMPUTATION_ENABLED", false) + cfg.dataStreamsMonitoringEnabled = provider.getBool("DD_DATA_STREAMS_ENABLED", false) + cfg.dynamicInstrumentationEnabled = provider.getBool("DD_DYNAMIC_INSTRUMENTATION_ENABLED", false) + cfg.globalSampleRate = provider.getFloat("DD_TRACE_SAMPLE_RATE", 0.0) + cfg.ciVisibilityEnabled = provider.getBool("DD_CIVISIBILITY_ENABLED", false) + cfg.ciVisibilityAgentless = provider.getBool("DD_CIVISIBILITY_AGENTLESS_ENABLED", false) + cfg.logDirectory = provider.getString("DD_TRACE_LOG_DIRECTORY", "") + cfg.traceRateLimitPerSecond = provider.getFloat("DD_TRACE_RATE_LIMIT", 0.0) + + return cfg +} + +// GlobalConfig returns the global configuration singleton. +// This function is thread-safe and can be called from multiple goroutines concurrently. +// The configuration is lazily initialized on first access using sync.Once, ensuring +// loadConfig() is called exactly once even under concurrent access. +func GlobalConfig() *Config { + if testing.Testing() { + globalConfig = loadConfig() + } else { + configOnce.Do(func() { + globalConfig = loadConfig() + }) + } + return globalConfig +} + +func (c *Config) IsDebugEnabled() bool { + return c.debug +} diff --git a/internal/config/configprovider.go b/internal/config/configprovider.go new file mode 100644 index 0000000000..e0577a779b --- /dev/null +++ b/internal/config/configprovider.go @@ -0,0 +1,214 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025 Datadog, Inc. + +package config + +import ( + "net/url" + "strconv" + "strings" + "time" + + "github.com/DataDog/dd-trace-go/v2/internal/telemetry" +) + +var provider = DefaultConfigProvider() + +type ConfigProvider struct { + sources []ConfigSource // In order of priority +} + +type ConfigSource interface { + Get(key string) string + Origin() telemetry.Origin +} + +func DefaultConfigProvider() *ConfigProvider { + return &ConfigProvider{ + sources: []ConfigSource{ + ManagedDeclarativeConfig, + new(envConfigSource), + new(otelEnvConfigSource), + LocalDeclarativeConfig, + }, + } +} + +func (p *ConfigProvider) getString(key string, def string) string { + // TODO: Eventually, iterate over all sources and report telemetry + for _, source := range p.sources { + if v := source.Get(key); v != "" { + var id string + // If source is a declarativeConfigSource, capture the config ID + if s, ok := source.(*declarativeConfigSource); ok { + id = s.GetID() // TODO: Store or use this config ID for telemetry + } + telemetry.RegisterAppConfigs(telemetry.Configuration{Name: key, Value: v, Origin: source.Origin(), ID: id}) + return v + } + } + telemetry.RegisterAppConfigs(telemetry.Configuration{Name: key, Value: def, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}) + return def +} + +func (p *ConfigProvider) getBool(key string, def bool) bool { + for _, source := range p.sources { + if v := source.Get(key); v != "" { + var id string + // If source is a declarativeConfigSource, capture the config ID + if s, ok := source.(*declarativeConfigSource); ok { + id = s.GetID() + } + if v == "true" { + telemetry.RegisterAppConfigs(telemetry.Configuration{Name: key, Value: v, Origin: source.Origin(), ID: id}) + return true + } else if v == "false" { + telemetry.RegisterAppConfigs(telemetry.Configuration{Name: key, Value: v, Origin: source.Origin(), ID: id}) + return false + } + } + } + telemetry.RegisterAppConfigs(telemetry.Configuration{Name: key, Value: def, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}) + return def +} + +func (p *ConfigProvider) getInt(key string, def int) int { + for _, source := range p.sources { + if v := source.Get(key); v != "" { + var id string + // If source is a declarativeConfigSource, capture the config ID + if s, ok := source.(*declarativeConfigSource); ok { + id = s.GetID() + } + intVal, err := strconv.Atoi(v) + if err == nil { + telemetry.RegisterAppConfigs(telemetry.Configuration{Name: key, Value: v, Origin: source.Origin(), ID: id}) + return intVal + } + } + } + telemetry.RegisterAppConfigs(telemetry.Configuration{Name: key, Value: def, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}) + return def +} + +func (p *ConfigProvider) getMap(key string, def map[string]string) map[string]string { + for _, source := range p.sources { + if v := source.Get(key); v != "" { + var id string + // If source is a declarativeConfigSource, capture the config ID + if s, ok := source.(*declarativeConfigSource); ok { + id = s.GetID() + } + m := parseMapString(v) + if len(m) > 0 { + telemetry.RegisterAppConfigs(telemetry.Configuration{Name: key, Value: v, Origin: source.Origin(), ID: id}) + return m + } + } + } + telemetry.RegisterAppConfigs(telemetry.Configuration{Name: key, Value: def, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}) + return def +} + +func (p *ConfigProvider) getDuration(key string, def time.Duration) time.Duration { + for _, source := range p.sources { + if v := source.Get(key); v != "" { + var id string + // If source is a declarativeConfigSource, capture the config ID + if s, ok := source.(*declarativeConfigSource); ok { + id = s.GetID() + } + d, err := time.ParseDuration(v) + if err == nil { + telemetry.RegisterAppConfigs(telemetry.Configuration{Name: key, Value: v, Origin: source.Origin(), ID: id}) + return d + } + } + } + telemetry.RegisterAppConfigs(telemetry.Configuration{Name: key, Value: def, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}) + return def +} + +func (p *ConfigProvider) getFloat(key string, def float64) float64 { + for _, source := range p.sources { + if v := source.Get(key); v != "" { + var id string + // If source is a declarativeConfigSource, capture the config ID + if s, ok := source.(*declarativeConfigSource); ok { + id = s.GetID() + } + floatVal, err := strconv.ParseFloat(v, 64) + if err == nil { + telemetry.RegisterAppConfigs(telemetry.Configuration{Name: key, Value: v, Origin: source.Origin(), ID: id}) + return floatVal + } + } + } + telemetry.RegisterAppConfigs(telemetry.Configuration{Name: key, Value: def, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}) + return def +} + +func (p *ConfigProvider) getURL(key string, def *url.URL) *url.URL { + for _, source := range p.sources { + if v := source.Get(key); v != "" { + var id string + // If source is a declarativeConfigSource, capture the config ID + if s, ok := source.(*declarativeConfigSource); ok { + id = s.GetID() + } + u, err := url.Parse(v) + if err == nil { + telemetry.RegisterAppConfigs(telemetry.Configuration{Name: key, Value: v, Origin: source.Origin(), ID: id}) + return u + } + } + } + telemetry.RegisterAppConfigs(telemetry.Configuration{Name: key, Value: def, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}) + return def +} + +// normalizeKey is a helper function for ConfigSource implementations to normalize the key to a valid environment variable name. +func normalizeKey(key string) string { + // Try to convert key to a valid environment variable name + if strings.HasPrefix(key, "DD_") || strings.HasPrefix(key, "OTEL_") { + return key + } + return "DD_" + strings.ToUpper(key) +} + +// parseMapString parses a string containing key:value pairs separated by comma or space. +// Format: "key1:value1,key2:value2" or "key1:value1 key2:value2" +func parseMapString(str string) map[string]string { + result := make(map[string]string) + + // Determine separator (comma or space) + sep := " " + if strings.Contains(str, ",") { + sep = "," + } + + // Parse each key:value pair + for _, pair := range strings.Split(str, sep) { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + + // Split on colon delimiter + kv := strings.SplitN(pair, ":", 2) + key := strings.TrimSpace(kv[0]) + if key == "" { + continue + } + + var val string + if len(kv) == 2 { + val = strings.TrimSpace(kv[1]) + } + result[key] = val + } + + return result +} diff --git a/internal/config/configprovider_test.go b/internal/config/configprovider_test.go new file mode 100644 index 0000000000..0e5f8eab28 --- /dev/null +++ b/internal/config/configprovider_test.go @@ -0,0 +1,444 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025 Datadog, Inc. + +package config + +import ( + "os" + "testing" + + "net/url" + "time" + + "github.com/DataDog/dd-trace-go/v2/internal/telemetry" + "github.com/DataDog/dd-trace-go/v2/internal/telemetry/telemetrytest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestConfigProvider(sources ...ConfigSource) *ConfigProvider { + return &ConfigProvider{ + sources: sources, + } +} + +type testConfigSource struct { + entries map[string]string + origin telemetry.Origin +} + +func newTestConfigSource(entries map[string]string, origin telemetry.Origin) *testConfigSource { + if entries == nil { + entries = make(map[string]string) + } + return &testConfigSource{ + entries: entries, + origin: origin, + } +} + +func (s *testConfigSource) Get(key string) string { + return s.entries[key] +} + +func (s *testConfigSource) Origin() telemetry.Origin { + return s.origin +} + +func TestGetMethods(t *testing.T) { + t.Run("defaults", func(t *testing.T) { + // Test that defaults are used when the queried key does not exist + provider := newTestConfigProvider(newTestConfigSource(nil, telemetry.OriginEnvVar)) + assert.Equal(t, "value", provider.getString("DD_SERVICE", "value")) + assert.Equal(t, true, provider.getBool("DD_TRACE_DEBUG", true)) + assert.Equal(t, 1, provider.getInt("DD_TRACE_PARTIAL_FLUSH_MIN_SPANS", 1)) + assert.Equal(t, 1.0, provider.getFloat("DD_TRACE_SAMPLE_RATE", 1.0)) + assert.Equal(t, &url.URL{Scheme: "http", Host: "localhost:8126"}, provider.getURL("DD_TRACE_AGENT_URL", &url.URL{Scheme: "http", Host: "localhost:8126"})) + }) + t.Run("non-defaults", func(t *testing.T) { + // Test that non-defaults are used when the queried key exists + entries := map[string]string{ + "DD_SERVICE": "string", + "DD_TRACE_DEBUG": "true", + "DD_TRACE_PARTIAL_FLUSH_MIN_SPANS": "1", + "DD_TRACE_SAMPLE_RATE": "1.0", + "DD_TRACE_AGENT_URL": "https://localhost:8126", + } + provider := newTestConfigProvider(newTestConfigSource(entries, telemetry.OriginEnvVar)) + assert.Equal(t, "string", provider.getString("DD_SERVICE", "value")) + assert.Equal(t, true, provider.getBool("DD_TRACE_DEBUG", false)) + assert.Equal(t, 1, provider.getInt("DD_TRACE_PARTIAL_FLUSH_MIN_SPANS", 0)) + assert.Equal(t, 1.0, provider.getFloat("DD_TRACE_SAMPLE_RATE", 0.0)) + assert.Equal(t, &url.URL{Scheme: "https", Host: "localhost:8126"}, provider.getURL("DD_TRACE_AGENT_URL", &url.URL{Scheme: "https", Host: "localhost:8126"})) + }) +} + +func TestDefaultConfigProvider(t *testing.T) { + t.Run("Settings only exist in EnvConfigSource", func(t *testing.T) { + // Setup: environment variables of each type + t.Setenv("DD_SERVICE", "string") + t.Setenv("DD_TRACE_DEBUG", "true") + t.Setenv("DD_TRACE_PARTIAL_FLUSH_MIN_SPANS", "1") + t.Setenv("DD_TRACE_SAMPLE_RATE", "1.0") + t.Setenv("DD_TRACE_AGENT_URL", "https://localhost:8126") + // TODO: Add more types as we go along + + provider := DefaultConfigProvider() + + // Configured values are returned correctly + assert.Equal(t, "string", provider.getString("DD_SERVICE", "value")) + assert.Equal(t, true, provider.getBool("DD_TRACE_DEBUG", false)) + assert.Equal(t, 1, provider.getInt("DD_TRACE_PARTIAL_FLUSH_MIN_SPANS", 0)) + assert.Equal(t, 1.0, provider.getFloat("DD_TRACE_SAMPLE_RATE", 0.0)) + assert.Equal(t, &url.URL{Scheme: "https", Host: "localhost:8126"}, provider.getURL("DD_TRACE_AGENT_URL", &url.URL{Scheme: "https", Host: "localhost:8126"})) + + // Defaults are returned for settings that are not configured + assert.Equal(t, "value", provider.getString("DD_ENV", "value")) + }) + + t.Run("Settings only exist in OtelEnvConfigSource", func(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "string") + t.Setenv("OTEL_LOG_LEVEL", "debug") + t.Setenv("OTEL_TRACES_SAMPLER", "parentbased_always_on") + t.Setenv("OTEL_TRACES_EXPORTER", "1.0") + t.Setenv("OTEL_PROPAGATORS", "https://localhost:8126") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "key1=value1,key2=value2") + + provider := DefaultConfigProvider() + + assert.Equal(t, "string", provider.getString("DD_SERVICE", "value")) + assert.Equal(t, true, provider.getBool("DD_TRACE_DEBUG", false)) + assert.Equal(t, 1.0, provider.getFloat("DD_TRACE_SAMPLE_RATE", 0)) + assert.Equal(t, 1.0, provider.getFloat("DD_TRACE_SAMPLE_RATE", 0.0)) + assert.Equal(t, &url.URL{Scheme: "https", Host: "localhost:8126"}, provider.getURL("DD_TRACE_AGENT_URL", &url.URL{Scheme: "https", Host: "localhost:8126"})) + assert.Equal(t, "key1:value1,key2:value2", provider.getString("DD_TAGS", "key:value")) + }) + t.Run("Settings only exist in LocalDeclarativeConfigSource", func(t *testing.T) { + const localYaml = ` +apm_configuration_default: + DD_SERVICE: local + DD_TRACE_DEBUG: true + DD_TRACE_PARTIAL_FLUSH_MIN_SPANS: "1" + DD_TRACE_SAMPLE_RATE: 1.0 + DD_TRACE_AGENT_URL: https://localhost:8126 +` + + tempLocalPath := "local.yml" + err := os.WriteFile(tempLocalPath, []byte(localYaml), 0644) + assert.NoError(t, err) + defer os.Remove(tempLocalPath) + + LocalDeclarativeConfig = newDeclarativeConfigSource(tempLocalPath, telemetry.OriginLocalStableConfig) + defer func() { + LocalDeclarativeConfig = newDeclarativeConfigSource(localFilePath, telemetry.OriginLocalStableConfig) + }() + + provider := DefaultConfigProvider() + + assert.Equal(t, "local", provider.getString("DD_SERVICE", "value")) + assert.Equal(t, true, provider.getBool("DD_TRACE_DEBUG", false)) + assert.Equal(t, 1, provider.getInt("DD_TRACE_PARTIAL_FLUSH_MIN_SPANS", 0)) + assert.Equal(t, 1.0, provider.getFloat("DD_TRACE_SAMPLE_RATE", 0.0)) + assert.Equal(t, &url.URL{Scheme: "https", Host: "localhost:8126"}, provider.getURL("DD_TRACE_AGENT_URL", &url.URL{Scheme: "https", Host: "localhost:8126"})) + + // Defaults are returned for settings that are not configured + assert.Equal(t, "value", provider.getString("DD_ENV", "value")) + }) + + t.Run("Settings only exist in ManagedDeclarativeConfigSource", func(t *testing.T) { + const managedYaml = ` +apm_configuration_default: + DD_SERVICE: managed + DD_TRACE_DEBUG: true + DD_TRACE_PARTIAL_FLUSH_MIN_SPANS: "1" + DD_TRACE_SAMPLE_RATE: 1.0 + DD_TRACE_AGENT_URL: https://localhost:8126` + + tempManagedPath := "managed.yml" + err := os.WriteFile(tempManagedPath, []byte(managedYaml), 0644) + assert.NoError(t, err) + defer os.Remove(tempManagedPath) + + ManagedDeclarativeConfig = newDeclarativeConfigSource(tempManagedPath, telemetry.OriginManagedStableConfig) + defer func() { + ManagedDeclarativeConfig = newDeclarativeConfigSource(managedFilePath, telemetry.OriginManagedStableConfig) + }() + + provider := DefaultConfigProvider() + + assert.Equal(t, "managed", provider.getString("DD_SERVICE", "value")) + assert.Equal(t, true, provider.getBool("DD_TRACE_DEBUG", false)) + assert.Equal(t, 1, provider.getInt("DD_TRACE_PARTIAL_FLUSH_MIN_SPANS", 0)) + assert.Equal(t, 1.0, provider.getFloat("DD_TRACE_SAMPLE_RATE", 0.0)) + assert.Equal(t, &url.URL{Scheme: "https", Host: "localhost:8126"}, provider.getURL("DD_TRACE_AGENT_URL", &url.URL{Scheme: "https", Host: "localhost:8126"})) + + // Defaults are returned for settings that are not configured + assert.Equal(t, "value", provider.getString("DD_ENV", "value")) + }) + t.Run("Settings exist in all ConfigSources", func(t *testing.T) { + // Priority order (highest to lowest): + // 1. ManagedDeclarativeConfig + // 2. EnvConfigSource (DD_* env vars) + // 3. OtelEnvConfigSource (OTEL_* env vars) + // 4. LocalDeclarativeConfig + + // Setup: Configure the same keys across multiple sources with different values + // to verify that the correct precedence is applied + + localYaml := ` +apm_configuration_default: + DD_SERVICE: local_service # Set in all 4 sources - should lose to Managed + DD_TRACE_DEBUG: false # Set in all 4 sources - should lose to Managed + DD_ENV: local_env # Set in 3 sources (Local, DD Env, OTEL) - should lose to DD Env + DD_VERSION: 0.1.0 # Set in 2 sources (Local, Managed) - should lose to Managed + DD_TRACE_SAMPLE_RATE: 0.1 # Set in 2 sources (Local, OTEL) - should lose to OTEL + DD_TRACE_STARTUP_LOGS: true # Only in Local - should WIN (lowest priority available) +` + + managedYaml := ` +apm_configuration_default: + DD_SERVICE: managed_service # Set in all 4 sources - should WIN (highest priority) + DD_TRACE_DEBUG: true # Set in all 4 sources - should WIN (highest priority) + DD_VERSION: 1.0.0 # Set in 2 sources (Local, Managed) - should WIN + DD_TRACE_PARTIAL_FLUSH_ENABLED: true # Set in 2 sources (Managed, DD Env) - should WIN +` + + // DD Env vars - priority level 2 + t.Setenv("DD_SERVICE", "env_service") // Set in all 4 sources - should lose to Managed + t.Setenv("DD_TRACE_DEBUG", "false") // Set in all 4 sources - should lose to Managed + t.Setenv("DD_ENV", "env_environment") // Set in 3 sources - should WIN (higher than OTEL and Local) + t.Setenv("DD_TRACE_PARTIAL_FLUSH_ENABLED", "false") // Set in 2 sources - should lose to Managed + t.Setenv("DD_TRACE_PARTIAL_FLUSH_MIN_SPANS", "100") // Only in DD Env - should WIN + + // OTEL Env vars - priority level 3 + t.Setenv("OTEL_SERVICE_NAME", "otel_service") // Set in all 4 sources (maps to DD_SERVICE) - should lose to Managed + t.Setenv("OTEL_LOG_LEVEL", "debug") // Set in all 4 sources (maps to DD_TRACE_DEBUG) - should lose to Managed + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment=otel_env,service.version=0.5.0") // Set in 3 sources - should lose to DD Env for DD_ENV, but provide version if not in higher sources + t.Setenv("OTEL_TRACES_SAMPLER", "traceidratio") // Set in 2 sources (OTEL, Local) - should WIN over Local (maps to DD_TRACE_SAMPLE_RATE) + t.Setenv("OTEL_TRACES_SAMPLER_ARG", "0.8") // Provides sample rate value of 0.8 + + // Create config files + tempLocalPath := "local.yml" + err := os.WriteFile(tempLocalPath, []byte(localYaml), 0644) + assert.NoError(t, err) + defer os.Remove(tempLocalPath) + + LocalDeclarativeConfig = newDeclarativeConfigSource(tempLocalPath, telemetry.OriginLocalStableConfig) + defer func() { + LocalDeclarativeConfig = newDeclarativeConfigSource(localFilePath, telemetry.OriginLocalStableConfig) + }() + + tempManagedPath := "managed.yml" + err = os.WriteFile(tempManagedPath, []byte(managedYaml), 0644) + assert.NoError(t, err) + defer os.Remove(tempManagedPath) + + ManagedDeclarativeConfig = newDeclarativeConfigSource(tempManagedPath, telemetry.OriginManagedStableConfig) + defer func() { + ManagedDeclarativeConfig = newDeclarativeConfigSource(managedFilePath, telemetry.OriginManagedStableConfig) + }() + + provider := DefaultConfigProvider() + + // Assertions grouped by which source should win + + // Managed Config wins (set in all 4 sources) + assert.Equal(t, "managed_service", provider.getString("DD_SERVICE", "default"), + "DD_SERVICE: Managed should win over DD Env, OTEL, and Local") + assert.Equal(t, true, provider.getBool("DD_TRACE_DEBUG", false), + "DD_TRACE_DEBUG: Managed should win over DD Env, OTEL, and Local") + + // Managed Config wins (set in 2 sources: Managed + one other) + assert.Equal(t, "1.0.0", provider.getString("DD_VERSION", "default"), + "DD_VERSION: Managed should win over Local") + assert.Equal(t, true, provider.getBool("DD_TRACE_PARTIAL_FLUSH_ENABLED", false), + "DD_TRACE_PARTIAL_FLUSH_ENABLED: Managed should win over DD Env") + + // DD Env wins (set in 3 sources: DD Env, OTEL, Local) + assert.Equal(t, "env_environment", provider.getString("DD_ENV", "default"), + "DD_ENV: DD Env should win over OTEL and Local") + + // DD Env wins (only in DD Env) + assert.Equal(t, 100, provider.getInt("DD_TRACE_PARTIAL_FLUSH_MIN_SPANS", 0), + "DD_TRACE_PARTIAL_FLUSH_MIN_SPANS: DD Env should win (only source)") + + // OTEL Env wins (set in 2 sources: OTEL, Local) + assert.Equal(t, 0.8, provider.getFloat("DD_TRACE_SAMPLE_RATE", 0.0), + "DD_TRACE_SAMPLE_RATE: OTEL should win over Local") + + // Local Config wins (only in Local) + assert.Equal(t, true, provider.getBool("DD_TRACE_STARTUP_LOGS", false), + "DD_TRACE_STARTUP_LOGS: Local should win (only source)") + + // Defaults are returned for settings not configured anywhere + assert.Equal(t, "default", provider.getString("DD_TRACE_AGENT_URL", "default"), + "Unconfigured setting should return default") + }) +} + +func TestConfigProviderTelemetryRegistration(t *testing.T) { + t.Run("env source reports telemetry for all getters", func(t *testing.T) { + telemetryClient := new(telemetrytest.MockClient) + // Expectations: value is the raw string from the source; ID is empty + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_SERVICE", Value: "service", Origin: telemetry.OriginEnvVar, ID: telemetry.EmptyID}}).Return() + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_DEBUG", Value: "true", Origin: telemetry.OriginEnvVar, ID: telemetry.EmptyID}}).Return() + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_PARTIAL_FLUSH_MIN_SPANS", Value: "100", Origin: telemetry.OriginEnvVar, ID: telemetry.EmptyID}}).Return() + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_SAMPLE_RATE", Value: "0.5", Origin: telemetry.OriginEnvVar, ID: telemetry.EmptyID}}).Return() + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_AGENT_URL", Value: "http://localhost:8126", Origin: telemetry.OriginEnvVar, ID: telemetry.EmptyID}}).Return() + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_SERVICE_MAPPING", Value: "old:new", Origin: telemetry.OriginEnvVar, ID: telemetry.EmptyID}}).Return() + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_ABANDONED_SPAN_TIMEOUT", Value: "10s", Origin: telemetry.OriginEnvVar, ID: telemetry.EmptyID}}).Return() + defer telemetry.MockClient(telemetryClient)() + + source := newTestConfigSource(map[string]string{ + "DD_SERVICE": "service", + "DD_TRACE_DEBUG": "true", + "DD_TRACE_PARTIAL_FLUSH_MIN_SPANS": "100", + "DD_TRACE_SAMPLE_RATE": "0.5", + "DD_TRACE_AGENT_URL": "http://localhost:8126", + "DD_SERVICE_MAPPING": "old:new", + "DD_TRACE_ABANDONED_SPAN_TIMEOUT": "10s", + }, telemetry.OriginEnvVar) + provider := newTestConfigProvider(source) + + _ = provider.getString("DD_SERVICE", "default") + _ = provider.getBool("DD_TRACE_DEBUG", false) + _ = provider.getInt("DD_TRACE_PARTIAL_FLUSH_MIN_SPANS", 0) + _ = provider.getFloat("DD_TRACE_SAMPLE_RATE", 0.0) + _ = provider.getURL("DD_TRACE_AGENT_URL", nil) + _ = provider.getMap("DD_SERVICE_MAPPING", nil) + _ = provider.getDuration("DD_TRACE_ABANDONED_SPAN_TIMEOUT", 0) + + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_SERVICE", Value: "service", Origin: telemetry.OriginEnvVar, ID: telemetry.EmptyID}}) + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_DEBUG", Value: "true", Origin: telemetry.OriginEnvVar, ID: telemetry.EmptyID}}) + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_PARTIAL_FLUSH_MIN_SPANS", Value: "100", Origin: telemetry.OriginEnvVar, ID: telemetry.EmptyID}}) + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_SAMPLE_RATE", Value: "0.5", Origin: telemetry.OriginEnvVar, ID: telemetry.EmptyID}}) + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_AGENT_URL", Value: "http://localhost:8126", Origin: telemetry.OriginEnvVar, ID: telemetry.EmptyID}}) + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_SERVICE_MAPPING", Value: "old:new", Origin: telemetry.OriginEnvVar, ID: telemetry.EmptyID}}) + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_ABANDONED_SPAN_TIMEOUT", Value: "10s", Origin: telemetry.OriginEnvVar, ID: telemetry.EmptyID}}) + }) + + t.Run("declarative source reports telemetry with ID", func(t *testing.T) { + telemetryClient := new(telemetrytest.MockClient) + // Values expected as raw strings, with OriginLocalStableConfig and ID from file + yaml := `config_id: 123 +apm_configuration_default: + DD_SERVICE: svc + DD_TRACE_DEBUG: true + DD_TRACE_PARTIAL_FLUSH_MIN_SPANS: "7" + DD_TRACE_SAMPLE_RATE: 0.9 + DD_TRACE_AGENT_URL: http://127.0.0.1:8126 + DD_SERVICE_MAPPING: a:b + DD_TRACE_ABANDONED_SPAN_TIMEOUT: 2s +` + temp := "decl.yml" + require.NoError(t, os.WriteFile(temp, []byte(yaml), 0644)) + defer os.Remove(temp) + + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_SERVICE", Value: "svc", Origin: telemetry.OriginLocalStableConfig, ID: "123"}}).Return() + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_DEBUG", Value: "true", Origin: telemetry.OriginLocalStableConfig, ID: "123"}}).Return() + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_PARTIAL_FLUSH_MIN_SPANS", Value: "7", Origin: telemetry.OriginLocalStableConfig, ID: "123"}}).Return() + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_SAMPLE_RATE", Value: "0.9", Origin: telemetry.OriginLocalStableConfig, ID: "123"}}).Return() + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_AGENT_URL", Value: "http://127.0.0.1:8126", Origin: telemetry.OriginLocalStableConfig, ID: "123"}}).Return() + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_SERVICE_MAPPING", Value: "a:b", Origin: telemetry.OriginLocalStableConfig, ID: "123"}}).Return() + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_ABANDONED_SPAN_TIMEOUT", Value: "2s", Origin: telemetry.OriginLocalStableConfig, ID: "123"}}).Return() + defer telemetry.MockClient(telemetryClient)() + + decl := newDeclarativeConfigSource(temp, telemetry.OriginLocalStableConfig) + provider := newTestConfigProvider(decl) + + _ = provider.getString("DD_SERVICE", "default") + _ = provider.getBool("DD_TRACE_DEBUG", false) + _ = provider.getInt("DD_TRACE_PARTIAL_FLUSH_MIN_SPANS", 0) + _ = provider.getFloat("DD_TRACE_SAMPLE_RATE", 0.0) + _ = provider.getURL("DD_TRACE_AGENT_URL", nil) + _ = provider.getMap("DD_SERVICE_MAPPING", nil) + _ = provider.getDuration("DD_TRACE_ABANDONED_SPAN_TIMEOUT", 0) + + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_SERVICE", Value: "svc", Origin: telemetry.OriginLocalStableConfig, ID: "123"}}) + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_DEBUG", Value: "true", Origin: telemetry.OriginLocalStableConfig, ID: "123"}}) + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_PARTIAL_FLUSH_MIN_SPANS", Value: "7", Origin: telemetry.OriginLocalStableConfig, ID: "123"}}) + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_SAMPLE_RATE", Value: "0.9", Origin: telemetry.OriginLocalStableConfig, ID: "123"}}) + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_AGENT_URL", Value: "http://127.0.0.1:8126", Origin: telemetry.OriginLocalStableConfig, ID: "123"}}) + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_SERVICE_MAPPING", Value: "a:b", Origin: telemetry.OriginLocalStableConfig, ID: "123"}}) + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: "DD_TRACE_ABANDONED_SPAN_TIMEOUT", Value: "2s", Origin: telemetry.OriginLocalStableConfig, ID: "123"}}) + }) + + t.Run("source priority with config IDs", func(t *testing.T) { + // Test that when multiple sources exist, only the winning source's + // telemetry (including its config ID) is registered + + yamlManaged := `config_id: managed-123 +apm_configuration_default: + DD_SERVICE: managed-service +` + yamlLocal := `config_id: local-456 +apm_configuration_default: + DD_SERVICE: local-service + DD_ENV: local-env +` + tempManaged := "test_managed.yml" + tempLocal := "test_local.yml" + + require.NoError(t, os.WriteFile(tempManaged, []byte(yamlManaged), 0644)) + require.NoError(t, os.WriteFile(tempLocal, []byte(yamlLocal), 0644)) + defer os.Remove(tempManaged) + defer os.Remove(tempLocal) + + managedSource := newDeclarativeConfigSource(tempManaged, telemetry.OriginManagedStableConfig) + localSource := newDeclarativeConfigSource(tempLocal, telemetry.OriginLocalStableConfig) + + // Managed has higher priority than Local + provider := newTestConfigProvider(managedSource, localSource) + + // For DD_SERVICE: managed wins, so telemetry gets ID "managed-123" + result := provider.getString("DD_SERVICE", "default") + assert.Equal(t, "managed-service", result) + + // For DD_ENV: local wins (managed doesn't have it), so telemetry gets ID "local-456" + env := provider.getString("DD_ENV", "default") + assert.Equal(t, "local-env", env) + }) + + t.Run("reports defaults via telemetry when key missing or invalid", func(t *testing.T) { + telemetryClient := new(telemetrytest.MockClient) + + strKey, strDef := "DD_SERVICE", "default_service" + boolKey, boolDef := "DD_TRACE_DEBUG", true + intKey, intDef := "DD_TRACE_PARTIAL_FLUSH_MIN_SPANS", 7 + floatKey, floatDef := "DD_TRACE_SAMPLE_RATE", 0.25 + durKey, durDef := "DD_TRACE_ABANDONED_SPAN_TIMEOUT", 42*time.Second + urlKey, urlDef := "DD_TRACE_AGENT_URL", &url.URL{Scheme: "http", Host: "localhost:9000"} + mapKey, mapDef := "DD_SERVICE_MAPPING", map[string]string{"a": "b"} + + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: strKey, Value: strDef, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}}).Return() + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: boolKey, Value: boolDef, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}}).Return() + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: intKey, Value: intDef, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}}).Return() + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: floatKey, Value: floatDef, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}}).Return() + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: durKey, Value: durDef, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}}).Return() + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: urlKey, Value: urlDef, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}}).Return() + telemetryClient.On("RegisterAppConfigs", []telemetry.Configuration{{Name: mapKey, Value: mapDef, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}}).Return() + defer telemetry.MockClient(telemetryClient)() + + // Use an empty test source to force defaults + provider := newTestConfigProvider(newTestConfigSource(map[string]string{}, telemetry.OriginEnvVar)) + + // Defaults should be used and reported + assert.Equal(t, strDef, provider.getString(strKey, strDef)) + assert.Equal(t, boolDef, provider.getBool(boolKey, boolDef)) + assert.Equal(t, intDef, provider.getInt(intKey, intDef)) + assert.Equal(t, floatDef, provider.getFloat(floatKey, floatDef)) + assert.Equal(t, durDef, provider.getDuration(durKey, durDef)) + assert.Equal(t, urlDef, provider.getURL(urlKey, urlDef)) + assert.Equal(t, mapDef, provider.getMap(mapKey, mapDef)) + + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: strKey, Value: strDef, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}}) + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: boolKey, Value: boolDef, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}}) + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: intKey, Value: intDef, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}}) + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: floatKey, Value: floatDef, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}}) + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: durKey, Value: durDef, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}}) + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: urlKey, Value: urlDef, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}}) + telemetryClient.AssertCalled(t, "RegisterAppConfigs", []telemetry.Configuration{{Name: mapKey, Value: mapDef, Origin: telemetry.OriginDefault, ID: telemetry.EmptyID}}) + }) +} diff --git a/internal/config/declarativeconfig.go b/internal/config/declarativeconfig.go new file mode 100644 index 0000000000..67efd4e9b2 --- /dev/null +++ b/internal/config/declarativeconfig.go @@ -0,0 +1,35 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025 Datadog, Inc. + +package config + +import "github.com/DataDog/dd-trace-go/v2/internal/telemetry" + +// declarativeConfig represents a configuration loaded from a YAML source file. +type declarativeConfig struct { + Config map[string]string `yaml:"apm_configuration_default,omitempty"` // Configuration key-value pairs. + ID string `yaml:"config_id,omitempty"` // Identifier for the config set. +} + +func (d *declarativeConfig) get(key string) string { + return d.Config[key] +} + +func (d *declarativeConfig) getID() string { + return d.ID +} + +// To be used by tests +// func (d *declarativeConfig) isEmpty() bool { +// return d.ID == telemetry.EmptyID && len(d.Config) == 0 +// } + +// emptyDeclarativeConfig creates and returns a new, empty declarativeConfig instance. +func emptyDeclarativeConfig() *declarativeConfig { + return &declarativeConfig{ + Config: make(map[string]string), + ID: telemetry.EmptyID, + } +} diff --git a/internal/config/declarativeconfigsource.go b/internal/config/declarativeconfigsource.go new file mode 100644 index 0000000000..a1bd14f9c9 --- /dev/null +++ b/internal/config/declarativeconfigsource.go @@ -0,0 +1,103 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025 Datadog, Inc. + +package config + +import ( + "os" + + "go.yaml.in/yaml/v3" + + "github.com/DataDog/dd-trace-go/v2/internal/log" + "github.com/DataDog/dd-trace-go/v2/internal/telemetry" +) + +const ( + // File paths are supported on linux only + localFilePath = "/etc/datadog-agent/application_monitoring.yaml" + managedFilePath = "/etc/datadog-agent/managed/datadog-agent/stable/application_monitoring.yaml" + + // maxFileSize defines the maximum size in bytes for declarative config files (4KB). This limit ensures predictable memory use and guards against malformed large files. + maxFileSize = 4 * 1024 +) + +// LocalDeclarativeConfig holds the configuration loaded from the user-managed file. +var LocalDeclarativeConfig = newDeclarativeConfigSource(localFilePath, telemetry.OriginLocalStableConfig) + +// ManagedDeclarativeConfig holds the configuration loaded from the fleet-managed file. +var ManagedDeclarativeConfig = newDeclarativeConfigSource(managedFilePath, telemetry.OriginManagedStableConfig) + +// declarativeConfigSource represents a source of declarative configuration loaded from a file. +type declarativeConfigSource struct { + filePath string // Path to the configuration file. + origin telemetry.Origin // Origin identifier for telemetry. + config *declarativeConfig // Parsed declarative configuration. +} + +func (d *declarativeConfigSource) Get(key string) string { + return d.config.get(normalizeKey(key)) +} + +func (d *declarativeConfigSource) GetID() string { + return d.config.getID() +} + +func (d *declarativeConfigSource) Origin() telemetry.Origin { + return d.origin +} + +// newDeclarativeConfigSource initializes a new declarativeConfigSource from the given file. +func newDeclarativeConfigSource(filePath string, origin telemetry.Origin) *declarativeConfigSource { + return &declarativeConfigSource{ + filePath: filePath, + origin: origin, + config: parseFile(filePath), + } +} + +// ParseFile reads and parses the config file at the given path. +// Returns an empty config if the file doesn't exist or is invalid. +func parseFile(filePath string) *declarativeConfig { + info, err := os.Stat(filePath) + if err != nil { + // It's expected that the declarative config file may not exist; its absence is not an error. + if !os.IsNotExist(err) { + log.Warn("Failed to stat declarative config file %q, dropping: %v", filePath, err.Error()) + } + return emptyDeclarativeConfig() + } + + if info.Size() > maxFileSize { + log.Warn("Declarative config file %s exceeds size limit (%d bytes > %d bytes), dropping", + filePath, info.Size(), maxFileSize) + return emptyDeclarativeConfig() + } + + data, err := os.ReadFile(filePath) + if err != nil { + // It's expected that the declarative config file may not exist; its absence is not an error. + if !os.IsNotExist(err) { + log.Warn("Failed to read declarative config file %q, dropping: %v", filePath, err.Error()) + } + return emptyDeclarativeConfig() + } + + return fileContentsToConfig(data, filePath) +} + +// fileContentsToConfig parses YAML data into a declarativeConfig struct. +// Returns an empty config if parsing fails or the data is malformed. +func fileContentsToConfig(data []byte, fileName string) *declarativeConfig { + dc := &declarativeConfig{} + err := yaml.Unmarshal(data, dc) + if err != nil { + log.Warn("Parsing declarative config file %s failed due to error, dropping: %v", fileName, err.Error()) + return emptyDeclarativeConfig() + } + if dc.Config == nil { + dc.Config = make(map[string]string) + } + return dc +} diff --git a/internal/config/envconfigsource.go b/internal/config/envconfigsource.go new file mode 100644 index 0000000000..10a92779c5 --- /dev/null +++ b/internal/config/envconfigsource.go @@ -0,0 +1,21 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025 Datadog, Inc. + +package config + +import ( + "github.com/DataDog/dd-trace-go/v2/internal/env" + "github.com/DataDog/dd-trace-go/v2/internal/telemetry" +) + +type envConfigSource struct{} + +func (e *envConfigSource) Get(key string) string { + return env.Get(normalizeKey(key)) +} + +func (e *envConfigSource) Origin() telemetry.Origin { + return telemetry.OriginEnvVar +} diff --git a/internal/config/otelenvconfigsource.go b/internal/config/otelenvconfigsource.go new file mode 100644 index 0000000000..12570ce930 --- /dev/null +++ b/internal/config/otelenvconfigsource.go @@ -0,0 +1,202 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025 Datadog, Inc. + +package config + +import ( + "fmt" + "strings" + + "github.com/DataDog/dd-trace-go/v2/internal" + "github.com/DataDog/dd-trace-go/v2/internal/env" + "github.com/DataDog/dd-trace-go/v2/internal/log" + "github.com/DataDog/dd-trace-go/v2/internal/telemetry" +) + +const ( + ddPrefix = "config_datadog:" + otelPrefix = "config_opentelemetry:" +) + +type otelEnvConfigSource struct{} + +func (o *otelEnvConfigSource) Get(key string) string { + ddKey := normalizeKey(key) + entry := otelConfigs[ddKey] + if entry == nil { + return "" + } + otVal := env.Get(entry.ot) + if otVal == "" { + return "" + } + if ddVal := env.Get(ddKey); ddVal != "" { + log.Warn("Both %q and %q are set, using %s=%s", entry.ot, ddKey, entry.ot, ddVal) + telemetryTags := []string{ddPrefix + strings.ToLower(ddKey), otelPrefix + strings.ToLower(entry.ot)} + telemetry.Count(telemetry.NamespaceTracers, "otel.env.hiding", telemetryTags).Submit(1) + return ddVal + } + val, err := entry.remapper(otVal) + if err != nil { + log.Warn("%s", err.Error()) + telemetryTags := []string{ddPrefix + strings.ToLower(ddKey), otelPrefix + strings.ToLower(entry.ot)} + telemetry.Count(telemetry.NamespaceTracers, "otel.env.invalid", telemetryTags).Submit(1) + return "" + } + return val +} + +func (o *otelEnvConfigSource) Origin() telemetry.Origin { + return telemetry.OriginEnvVar +} + +type otelDDEnv struct { + ot string + remapper func(string) (string, error) +} + +var otelConfigs = map[string]*otelDDEnv{ + "DD_SERVICE": { + ot: "OTEL_SERVICE_NAME", + remapper: mapService, + }, + "DD_RUNTIME_METRICS_ENABLED": { + ot: "OTEL_METRICS_EXPORTER", + remapper: mapMetrics, + }, + "DD_TRACE_DEBUG": { + ot: "OTEL_LOG_LEVEL", + remapper: mapLogLevel, + }, + "DD_TRACE_ENABLED": { + ot: "OTEL_TRACES_EXPORTER", + remapper: mapEnabled, + }, + "DD_TRACE_SAMPLE_RATE": { + ot: "OTEL_TRACES_SAMPLER", + remapper: mapSampleRate, + }, + "DD_TRACE_PROPAGATION_STYLE": { + ot: "OTEL_PROPAGATORS", + remapper: mapPropagationStyle, + }, + "DD_TAGS": { + ot: "OTEL_RESOURCE_ATTRIBUTES", + remapper: mapDDTags, + }, +} + +var ddTagsMapping = map[string]string{ + "service.name": "service", + "deployment.environment": "env", + "service.version": "version", +} + +var unsupportedSamplerMapping = map[string]string{ + "always_on": "parentbased_always_on", + "always_off": "parentbased_always_off", + "traceidratio": "parentbased_traceidratio", +} + +var propagationMapping = map[string]string{ + "tracecontext": "tracecontext", + "b3": "b3 single header", + "b3multi": "b3multi", + "datadog": "datadog", + "none": "none", +} + +// mapService maps OTEL_SERVICE_NAME to DD_SERVICE +func mapService(ot string) (string, error) { + return ot, nil +} + +// mapMetrics maps OTEL_METRICS_EXPORTER to DD_RUNTIME_METRICS_ENABLED +func mapMetrics(ot string) (string, error) { + ot = strings.TrimSpace(strings.ToLower(ot)) + if ot == "none" { + return "false", nil + } + return "", fmt.Errorf("the following configuration is not supported: OTEL_METRICS_EXPORTER=%v", ot) +} + +// mapLogLevel maps OTEL_LOG_LEVEL to DD_TRACE_DEBUG +func mapLogLevel(ot string) (string, error) { + if strings.TrimSpace(strings.ToLower(ot)) == "debug" { + return "true", nil + } + return "", fmt.Errorf("the following configuration is not supported: OTEL_LOG_LEVEL=%v", ot) +} + +// mapEnabled maps OTEL_TRACES_EXPORTER to DD_TRACE_ENABLED +func mapEnabled(ot string) (string, error) { + if strings.TrimSpace(strings.ToLower(ot)) == "none" { + return "false", nil + } + return "", fmt.Errorf("the following configuration is not supported: OTEL_TRACES_EXPORTER=%v", ot) +} + +// mapSampleRate maps OTEL_TRACES_SAMPLER to DD_TRACE_SAMPLE_RATE +func otelTraceIDRatio() string { + if v := env.Get("OTEL_TRACES_SAMPLER_ARG"); v != "" { + return v + } + return "1.0" +} + +// mapSampleRate maps OTEL_TRACES_SAMPLER to DD_TRACE_SAMPLE_RATE +func mapSampleRate(ot string) (string, error) { + ot = strings.TrimSpace(strings.ToLower(ot)) + if v, ok := unsupportedSamplerMapping[ot]; ok { + log.Warn("The following configuration is not supported: OTEL_TRACES_SAMPLER=%s. %s will be used", ot, v) + ot = v + } + + var samplerMapping = map[string]string{ + "parentbased_always_on": "1.0", + "parentbased_always_off": "0.0", + "parentbased_traceidratio": otelTraceIDRatio(), + } + if v, ok := samplerMapping[ot]; ok { + return v, nil + } + return "", fmt.Errorf("unknown sampling configuration %v", ot) +} + +// mapPropagationStyle maps OTEL_PROPAGATORS to DD_TRACE_PROPAGATION_STYLE +func mapPropagationStyle(ot string) (string, error) { + ot = strings.TrimSpace(strings.ToLower(ot)) + supportedStyles := make([]string, 0) + for _, otStyle := range strings.Split(ot, ",") { + otStyle = strings.TrimSpace(otStyle) + if _, ok := propagationMapping[otStyle]; ok { + supportedStyles = append(supportedStyles, propagationMapping[otStyle]) + } else { + log.Warn("Invalid configuration: %q is not supported. This propagation style will be ignored.", otStyle) + } + } + return strings.Join(supportedStyles, ","), nil +} + +// mapDDTags maps OTEL_RESOURCE_ATTRIBUTES to DD_TAGS +func mapDDTags(ot string) (string, error) { + ddTags := make([]string, 0) + internal.ForEachStringTag(ot, internal.OtelTagsDelimeter, func(key, val string) { + // replace otel delimiter with dd delimiter and normalize tag names + if ddkey, ok := ddTagsMapping[key]; ok { + // map reserved otel tag names to dd tag names + ddTags = append([]string{ddkey + internal.DDTagsDelimiter + val}, ddTags...) + } else { + ddTags = append(ddTags, key+internal.DDTagsDelimiter+val) + } + }) + + if len(ddTags) > 10 { + log.Warn("The following resource attributes have been dropped: %v. Only the first 10 resource attributes will be applied: %s", ddTags[10:], ddTags[:10]) //nolint:gocritic // Slice logging for debugging + ddTags = ddTags[:10] + } + + return strings.Join(ddTags, ","), nil +}