From 8ae0924e485ead6f58f00b50d7aa6cb7750102b6 Mon Sep 17 00:00:00 2001 From: Rachel Yang Date: Tue, 13 Jan 2026 16:15:42 -0500 Subject: [PATCH 1/7] WIP: feat(otel): adding support for OpenTelemetry logs --- ddtrace/opentelemetry/log/correlation.go | 95 +++++ ddtrace/opentelemetry/log/exporter.go | 426 +++++++++++++++++++ ddtrace/opentelemetry/log/integration.go | 53 +++ ddtrace/opentelemetry/log/logger.go | 45 ++ ddtrace/opentelemetry/log/logger_provider.go | 145 +++++++ ddtrace/opentelemetry/log/resource.go | 168 ++++++++ go.mod | 5 +- go.sum | 8 + internal/config/config.go | 16 + internal/env/supported_configurations.gen.go | 11 + internal/env/supported_configurations.json | 37 +- 11 files changed, 1006 insertions(+), 3 deletions(-) create mode 100644 ddtrace/opentelemetry/log/correlation.go create mode 100644 ddtrace/opentelemetry/log/exporter.go create mode 100644 ddtrace/opentelemetry/log/integration.go create mode 100644 ddtrace/opentelemetry/log/logger.go create mode 100644 ddtrace/opentelemetry/log/logger_provider.go create mode 100644 ddtrace/opentelemetry/log/resource.go diff --git a/ddtrace/opentelemetry/log/correlation.go b/ddtrace/opentelemetry/log/correlation.go new file mode 100644 index 0000000000..ef491706c7 --- /dev/null +++ b/ddtrace/opentelemetry/log/correlation.go @@ -0,0 +1,95 @@ +// 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 log + +import ( + "context" + "encoding/binary" + + "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer" + + oteltrace "go.opentelemetry.io/otel/trace" +) + +// ddSpanWrapper wraps a Datadog span to implement the OTel Span interface minimally. +// This allows DD spans to be visible to OTel APIs like trace.SpanFromContext. +type ddSpanWrapper struct { + oteltrace.Span // Embed noop span for unimplemented methods + dd *tracer.Span + spanContext oteltrace.SpanContext +} + +// SpanContext returns the OTel SpanContext derived from the DD span. +func (w *ddSpanWrapper) SpanContext() oteltrace.SpanContext { + return w.spanContext +} + +// IsRecording returns true if the span is recording. +func (w *ddSpanWrapper) IsRecording() bool { + // DD spans are always recording if not finished + return true +} + +// ddSpanContextToOtel converts a Datadog span context to an OTel SpanContext. +func ddSpanContextToOtel(ddCtx *tracer.SpanContext) oteltrace.SpanContext { + // Convert DD trace ID (128-bit) to OTel TraceID + var traceID oteltrace.TraceID + traceID = ddCtx.TraceIDBytes() + + // Convert DD span ID (64-bit) to OTel SpanID (64-bit) + var spanID oteltrace.SpanID + binary.BigEndian.PutUint64(spanID[:], ddCtx.SpanID()) + + // Create OTel span context + config := oteltrace.SpanContextConfig{ + TraceID: traceID, + SpanID: spanID, + TraceFlags: oteltrace.FlagsSampled, // DD spans are sampled by default + Remote: false, + } + + return oteltrace.NewSpanContext(config) +} + +// contextWithDDSpan wraps a Datadog span in an OpenTelemetry span context and adds it to the context. +// This allows OpenTelemetry APIs like trace.SpanFromContext to find the Datadog span. +// +// If the context already has an OpenTelemetry span, it is preserved. +// If there's a Datadog span in the context but no OpenTelemetry span, this creates a bridge. +// +// This function is internal and used automatically by ddAwareLogger. +func contextWithDDSpan(ctx context.Context) context.Context { + // Check if there's already an OTel span in the context + if oteltrace.SpanFromContext(ctx).SpanContext().IsValid() { + // OTel span already present, no need to bridge + return ctx + } + + // Check if there's a DD span in the context + ddSpan, ok := tracer.SpanFromContext(ctx) + if !ok { + // No DD span, return original context + return ctx + } + + // Create an OTel span wrapper for the DD span + otelSpanCtx := ddSpanContextToOtel(ddSpan.Context()) + + // Verify the span context is valid + if !otelSpanCtx.IsValid() { + // Something went wrong with conversion, return original context + return ctx + } + + wrapper := &ddSpanWrapper{ + Span: oteltrace.SpanFromContext(ctx), // Use existing (noop) span + dd: ddSpan, + spanContext: otelSpanCtx, + } + + // Add the wrapped span to the context + return oteltrace.ContextWithSpan(ctx, wrapper) +} diff --git a/ddtrace/opentelemetry/log/exporter.go b/ddtrace/opentelemetry/log/exporter.go new file mode 100644 index 0000000000..0b7379f609 --- /dev/null +++ b/ddtrace/opentelemetry/log/exporter.go @@ -0,0 +1,426 @@ +// 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 log + +import ( + "context" + "fmt" + "net" + "net/url" + "strconv" + "strings" + "time" + + "github.com/DataDog/dd-trace-go/v2/internal/env" + "github.com/DataDog/dd-trace-go/v2/internal/log" + + "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" + sdklog "go.opentelemetry.io/otel/sdk/log" +) + +const ( + // Default OTLP endpoint for Datadog Agent logs + defaultOTLPHTTPPort = "4318" + defaultOTLPGRPCPort = "4317" + defaultOTLPLogsPath = "/v1/logs" + defaultOTLPProtocol = "http/json" + + // OTLP environment variables (logs-specific) + envOTLPLogsEndpoint = "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT" + envOTLPLogsProtocol = "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL" + envOTLPLogsHeaders = "OTEL_EXPORTER_OTLP_LOGS_HEADERS" + envOTLPLogsTimeout = "OTEL_EXPORTER_OTLP_LOGS_TIMEOUT" + + // OTLP environment variables (generic) + envOTLPEndpoint = "OTEL_EXPORTER_OTLP_ENDPOINT" + envOTLPProtocol = "OTEL_EXPORTER_OTLP_PROTOCOL" + envOTLPHeaders = "OTEL_EXPORTER_OTLP_HEADERS" + envOTLPTimeout = "OTEL_EXPORTER_OTLP_TIMEOUT" + + // DD environment variables for agent configuration + envDDTraceAgentURL = "DD_TRACE_AGENT_URL" + envDDAgentHost = "DD_AGENT_HOST" + envDDTraceAgentPort = "DD_TRACE_AGENT_PORT" + + // BatchLogRecordProcessor environment variables + envBLRPMaxQueueSize = "OTEL_BLRP_MAX_QUEUE_SIZE" + envBLRPScheduleDelay = "OTEL_BLRP_SCHEDULE_DELAY" + envBLRPExportTimeout = "OTEL_BLRP_EXPORT_TIMEOUT" + envBLRPMaxExportBatchSize = "OTEL_BLRP_MAX_EXPORT_BATCH_SIZE" + + // Default values for BatchLogRecordProcessor + defaultBLRPMaxQueueSize = 2048 + defaultBLRPScheduleDelay = 1000 * time.Millisecond + defaultBLRPExportTimeout = 30000 * time.Millisecond + defaultBLRPMaxExportBatchSize = 512 +) + +// newOTLPExporter creates an OTLP exporter (HTTP or gRPC) configured with Datadog-specific defaults. +// +// Protocol selection priority: +// 1. OTEL_EXPORTER_OTLP_LOGS_PROTOCOL +// 2. OTEL_EXPORTER_OTLP_PROTOCOL +// 3. Default: http/json +// +// Supported protocols: +// - "http/json": HTTP with JSON encoding (default) +// - "http/protobuf" or "http": HTTP with protobuf encoding +// - "grpc": gRPC +// +// Endpoint resolution priority: +// 1. OTEL_EXPORTER_OTLP_LOGS_ENDPOINT (highest priority) +// 2. OTEL_EXPORTER_OTLP_ENDPOINT +// 3. DD_TRACE_AGENT_URL hostname with appropriate port +// 4. DD_AGENT_HOST with appropriate port +// 5. localhost with default port (default) +func newOTLPExporter(ctx context.Context, httpOpts []otlploghttp.Option, grpcOpts []otlploggrpc.Option) (sdklog.Exporter, error) { + // Determine protocol + protocol := resolveOTLPProtocol() + + switch protocol { + case "grpc": + return newOTLPGRPCExporter(ctx, grpcOpts...) + case "http/json", "http/protobuf", "http": + return newOTLPHTTPExporter(ctx, httpOpts...) + default: + log.Warn("Unknown OTLP logs protocol %q, defaulting to %s", protocol, defaultOTLPProtocol) + return newOTLPHTTPExporter(ctx, httpOpts...) + } +} + +// resolveOTLPProtocol returns the OTLP protocol from environment variables. +// Priority: OTEL_EXPORTER_OTLP_LOGS_PROTOCOL > OTEL_EXPORTER_OTLP_PROTOCOL > "http/json" +func resolveOTLPProtocol() string { + // Check logs-specific protocol first + if protocol := env.Get(envOTLPLogsProtocol); protocol != "" { + return strings.ToLower(strings.TrimSpace(protocol)) + } + // Fall back to general OTLP protocol + if protocol := env.Get(envOTLPProtocol); protocol != "" { + return strings.ToLower(strings.TrimSpace(protocol)) + } + // Default to HTTP with JSON + return defaultOTLPProtocol +} + +// newOTLPHTTPExporter creates an OTLP HTTP exporter configured with Datadog-specific defaults. +func newOTLPHTTPExporter(ctx context.Context, opts ...otlploghttp.Option) (sdklog.Exporter, error) { + // Build exporter options with DD defaults + exporterOpts := buildHTTPExporterOptions(opts...) + + // Create the OTLP HTTP exporter + exporter, err := otlploghttp.New(ctx, exporterOpts...) + if err != nil { + return nil, fmt.Errorf("failed to create OTLP HTTP logs exporter: %w", err) + } + + return exporter, nil +} + +// newOTLPGRPCExporter creates an OTLP gRPC exporter configured with Datadog-specific defaults. +func newOTLPGRPCExporter(ctx context.Context, opts ...otlploggrpc.Option) (sdklog.Exporter, error) { + // Build exporter options with DD defaults + exporterOpts := buildGRPCExporterOptions(opts...) + + // Create the OTLP gRPC exporter + exporter, err := otlploggrpc.New(ctx, exporterOpts...) + if err != nil { + return nil, fmt.Errorf("failed to create OTLP gRPC logs exporter: %w", err) + } + + return exporter, nil +} + +// buildHTTPExporterOptions constructs the OTLP HTTP exporter options with DD-specific defaults +func buildHTTPExporterOptions(userOpts ...otlploghttp.Option) []otlploghttp.Option { + opts := []otlploghttp.Option{ + // Set timeout + otlploghttp.WithTimeout(resolveExportTimeout()), + // Set retry configuration + otlploghttp.WithRetry(httpRetryConfig()), + } + + // Only set endpoint if not already set by OTEL environment variables + if !hasOTLPEndpointInEnv() { + endpoint, path, insecure := resolveOTLPEndpointHTTP() + opts = append(opts, otlploghttp.WithEndpoint(endpoint)) + opts = append(opts, otlploghttp.WithURLPath(path)) + if insecure { + opts = append(opts, otlploghttp.WithInsecure()) + } + } + + // Set headers if configured + if headers := resolveHeaders(); len(headers) > 0 { + opts = append(opts, otlploghttp.WithHeaders(headers)) + } + + // Add user-provided options last so they can override defaults + opts = append(opts, userOpts...) + + return opts +} + +// buildGRPCExporterOptions constructs the OTLP gRPC exporter options with DD-specific defaults +func buildGRPCExporterOptions(userOpts ...otlploggrpc.Option) []otlploggrpc.Option { + opts := []otlploggrpc.Option{ + // Set timeout + otlploggrpc.WithTimeout(resolveExportTimeout()), + // Set retry config + otlploggrpc.WithRetry(grpcRetryConfig()), + } + + // Only set endpoint if not already set by OTEL environment variables + if !hasOTLPEndpointInEnv() { + endpoint, insecure := resolveOTLPEndpointGRPC() + opts = append(opts, otlploggrpc.WithEndpoint(endpoint)) + if insecure { + opts = append(opts, otlploggrpc.WithInsecure()) + } + } + + // Set headers if configured + if headers := resolveHeaders(); len(headers) > 0 { + opts = append(opts, otlploggrpc.WithHeaders(headers)) + } + + // Add user-provided options last so they can override defaults + opts = append(opts, userOpts...) + + return opts +} + +// hasOTLPEndpointInEnv checks if OTLP endpoint is configured via OTEL environment variables +func hasOTLPEndpointInEnv() bool { + if v := env.Get(envOTLPLogsEndpoint); v != "" { + return true + } + if v := env.Get(envOTLPEndpoint); v != "" { + return true + } + return false +} + +// resolveOTLPEndpointHTTP determines the OTLP HTTP endpoint from DD agent configuration. +// Returns (endpoint, path, insecure) where: +// - endpoint is the host:port (e.g., "localhost:4318") +// - path is the URL path (e.g., "/v1/logs") +// - insecure indicates whether to use http (true) or https (false) +// +// Priority order: +// 1. DD_TRACE_AGENT_URL with port changed to 4318 +// 2. DD_AGENT_HOST:4318 +// 3. localhost:4318 (default) +func resolveOTLPEndpointHTTP() (endpoint, path string, insecure bool) { + path = defaultOTLPLogsPath + insecure = true // default to http + + // Check DD_TRACE_AGENT_URL first + if agentURL := env.Get(envDDTraceAgentURL); agentURL != "" { + u, err := url.Parse(agentURL) + if err != nil { + log.Warn("Failed to parse DD_TRACE_AGENT_URL for logs: %s, using default", err.Error()) + } else { + // Extract hostname from the agent URL and use port 4318 + hostname := u.Hostname() + if hostname != "" { + endpoint = net.JoinHostPort(hostname, defaultOTLPHTTPPort) + // Preserve the scheme from DD_TRACE_AGENT_URL + insecure = (u.Scheme == "http" || u.Scheme == "unix") + log.Debug("Using OTLP logs endpoint from DD_TRACE_AGENT_URL: %s", endpoint) + return + } + } + } + + // Check DD_AGENT_HOST + if host := env.Get(envDDAgentHost); host != "" { + endpoint = net.JoinHostPort(host, defaultOTLPHTTPPort) + insecure = true + log.Debug("Using OTLP logs endpoint from DD_AGENT_HOST: %s", endpoint) + return + } + + // Default to localhost:4318 + endpoint = "localhost:4318" + insecure = true + log.Debug("Using default OTLP logs endpoint: %s", endpoint) + return +} + +// resolveOTLPEndpointGRPC determines the OTLP gRPC endpoint from DD agent configuration. +// Returns (endpoint, insecure) where: +// - endpoint is the host:port (e.g., "localhost:4317") +// - insecure indicates whether to use grpc (true) or grpcs (false) +// +// Priority order: +// 1. DD_TRACE_AGENT_URL with port changed to 4317 +// 2. DD_AGENT_HOST:4317 +// 3. localhost:4317 (default) +func resolveOTLPEndpointGRPC() (endpoint string, insecure bool) { + insecure = true // default to grpc (not grpcs) + + // Check DD_TRACE_AGENT_URL first + if agentURL := env.Get(envDDTraceAgentURL); agentURL != "" { + u, err := url.Parse(agentURL) + if err != nil { + log.Warn("Failed to parse DD_TRACE_AGENT_URL for logs: %s, using default", err.Error()) + } else { + // Extract hostname from the agent URL and use port 4317 for gRPC + hostname := u.Hostname() + if hostname != "" { + endpoint = net.JoinHostPort(hostname, defaultOTLPGRPCPort) + // Preserve the scheme from DD_TRACE_AGENT_URL + insecure = (u.Scheme == "http" || u.Scheme == "unix") + log.Debug("Using OTLP gRPC logs endpoint from DD_TRACE_AGENT_URL: %s", endpoint) + return + } + } + } + + // Check DD_AGENT_HOST + if host := env.Get(envDDAgentHost); host != "" { + endpoint = net.JoinHostPort(host, defaultOTLPGRPCPort) + log.Debug("Using OTLP gRPC logs endpoint from DD_AGENT_HOST: %s", endpoint) + return + } + + // Default to localhost:4317 + endpoint = net.JoinHostPort("localhost", defaultOTLPGRPCPort) + log.Debug("Using default OTLP gRPC logs endpoint: %s", endpoint) + return +} + +// resolveHeaders returns the headers to send with OTLP requests. +// Priority: OTEL_EXPORTER_OTLP_LOGS_HEADERS > OTEL_EXPORTER_OTLP_HEADERS +// Format: k=v,k2=v2 (spaces are trimmed, invalid entries are ignored) +func resolveHeaders() map[string]string { + // Check logs-specific headers first + if headersStr := env.Get(envOTLPLogsHeaders); headersStr != "" { + return parseHeaders(headersStr) + } + // Fall back to general OTLP headers + if headersStr := env.Get(envOTLPHeaders); headersStr != "" { + return parseHeaders(headersStr) + } + return nil +} + +// parseHeaders parses header string in format "k=v,k2=v2" +// Spaces are trimmed, invalid entries (no '=') are silently ignored +func parseHeaders(str string) map[string]string { + headers := make(map[string]string) + for _, entry := range strings.Split(str, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + parts := strings.SplitN(entry, "=", 2) + if len(parts) != 2 { + // Invalid entry, skip it + continue + } + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + if key != "" { + headers[key] = val + } + } + return headers +} + +// resolveExportTimeout returns the export timeout from environment variables. +// Priority: OTEL_EXPORTER_OTLP_LOGS_TIMEOUT > OTEL_EXPORTER_OTLP_TIMEOUT > default (30s) +func resolveExportTimeout() time.Duration { + // Check logs-specific timeout first + if timeoutStr := env.Get(envOTLPLogsTimeout); timeoutStr != "" { + if timeout, err := parseTimeout(timeoutStr); err == nil { + return timeout + } + } + // Fall back to general OTLP timeout + if timeoutStr := env.Get(envOTLPTimeout); timeoutStr != "" { + if timeout, err := parseTimeout(timeoutStr); err == nil { + return timeout + } + } + // Default to 30 seconds + return 30 * time.Second +} + +// parseTimeout parses timeout string (milliseconds as integer) +func parseTimeout(str string) (time.Duration, error) { + ms, err := strconv.ParseInt(str, 10, 64) + if err != nil { + return 0, err + } + return time.Duration(ms) * time.Millisecond, nil +} + +// httpRetryConfig returns the retry configuration for OTLP HTTP exporter. +func httpRetryConfig() otlploghttp.RetryConfig { + return otlploghttp.RetryConfig{ + Enabled: true, + InitialInterval: 1 * time.Second, + MaxInterval: 30 * time.Second, + MaxElapsedTime: 5 * time.Minute, + } +} + +// grpcRetryConfig returns the retry configuration for OTLP gRPC exporter. +func grpcRetryConfig() otlploggrpc.RetryConfig { + return otlploggrpc.RetryConfig{ + Enabled: true, + InitialInterval: 5 * time.Second, + MaxInterval: 30 * time.Second, + MaxElapsedTime: 5 * time.Minute, + } +} + +// resolveBLRPMaxQueueSize returns the max queue size for BatchLogRecordProcessor. +// Default: 2048 +func resolveBLRPMaxQueueSize() int { + if sizeStr := env.Get(envBLRPMaxQueueSize); sizeStr != "" { + if size, err := strconv.Atoi(sizeStr); err == nil && size > 0 { + return size + } + } + return defaultBLRPMaxQueueSize +} + +// resolveBLRPScheduleDelay returns the schedule delay for BatchLogRecordProcessor. +// Default: 1000ms +func resolveBLRPScheduleDelay() time.Duration { + if delayStr := env.Get(envBLRPScheduleDelay); delayStr != "" { + if delay, err := parseTimeout(delayStr); err == nil { + return delay + } + } + return defaultBLRPScheduleDelay +} + +// resolveBLRPExportTimeout returns the export timeout for BatchLogRecordProcessor. +// Default: 30000ms +func resolveBLRPExportTimeout() time.Duration { + if timeoutStr := env.Get(envBLRPExportTimeout); timeoutStr != "" { + if timeout, err := parseTimeout(timeoutStr); err == nil { + return timeout + } + } + return defaultBLRPExportTimeout +} + +// resolveBLRPMaxExportBatchSize returns the max export batch size for BatchLogRecordProcessor. +// Default: 512 +func resolveBLRPMaxExportBatchSize() int { + if sizeStr := env.Get(envBLRPMaxExportBatchSize); sizeStr != "" { + if size, err := strconv.Atoi(sizeStr); err == nil && size > 0 { + return size + } + } + return defaultBLRPMaxExportBatchSize +} diff --git a/ddtrace/opentelemetry/log/integration.go b/ddtrace/opentelemetry/log/integration.go new file mode 100644 index 0000000000..1a88879e2c --- /dev/null +++ b/ddtrace/opentelemetry/log/integration.go @@ -0,0 +1,53 @@ +// 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 log + +import ( + "context" + "time" + + "github.com/DataDog/dd-trace-go/v2/internal/config" + "github.com/DataDog/dd-trace-go/v2/internal/log" +) + +// StartIfEnabled initializes the OTel LoggerProvider if DD_LOGS_OTEL_ENABLED=true. +// This function should be called during tracer initialization. +// +// If the feature is not enabled, this function is a no-op. +// Returns an error if initialization fails when the feature is enabled. +func StartIfEnabled(ctx context.Context) error { + cfg := config.Get() + if !cfg.LogsOtelEnabled() { + log.Debug("DD_LOGS_OTEL_ENABLED=false, skipping OTel LoggerProvider initialization") + return nil + } + + log.Debug("DD_LOGS_OTEL_ENABLED=true, initializing OTel LoggerProvider") + return InitGlobalLoggerProvider(ctx) +} + +// StopIfEnabled shuts down the OTel LoggerProvider if it was initialized. +// This function should be called during tracer shutdown. +// +// It flushes any pending log records and cleans up resources. +// The shutdown operation will timeout after 5 seconds to avoid blocking indefinitely. +func StopIfEnabled() { + provider := GetGlobalLoggerProvider() + if provider == nil { + // Not initialized, nothing to do + return + } + + log.Debug("Shutting down OTel LoggerProvider") + + // Use a timeout context to avoid blocking indefinitely + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := ShutdownGlobalLoggerProvider(ctx); err != nil { + log.Warn("Error shutting down OTel LoggerProvider: %v", err) + } +} diff --git a/ddtrace/opentelemetry/log/logger.go b/ddtrace/opentelemetry/log/logger.go new file mode 100644 index 0000000000..497487fd01 --- /dev/null +++ b/ddtrace/opentelemetry/log/logger.go @@ -0,0 +1,45 @@ +// 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 log + +import ( + "context" + + "go.opentelemetry.io/otel/log" + "go.opentelemetry.io/otel/log/embedded" +) + +// ddAwareLogger wraps an OTel Logger and automatically bridges Datadog spans +// to OpenTelemetry span context before emitting logs. This ensures that logs +// emitted with a Datadog span in the context are correlated with the correct +// trace and span IDs. +// +// This type implements the otel/log.Logger interface and is not exported. +type ddAwareLogger struct { + embedded.Logger + underlying log.Logger +} + +// Emit emits a log record with automatic Datadog span bridging. +// If the context contains a Datadog span but no OpenTelemetry span, it bridges +// the Datadog span to OpenTelemetry context before calling the underlying logger. +func (l *ddAwareLogger) Emit(ctx context.Context, record log.Record) { + // Automatically bridge Datadog spans if present + bridgedCtx := contextWithDDSpan(ctx) + l.underlying.Emit(bridgedCtx, record) +} + +// Enabled returns whether the logger is enabled for the given level and context. +func (l *ddAwareLogger) Enabled(ctx context.Context, param log.EnabledParameters) bool { + // Bridge context for consistency (in case Enabled checks span context) + ctx = contextWithDDSpan(ctx) + return l.underlying.Enabled(ctx, param) +} + +// newDDAwareLogger wraps an OpenTelemetry logger with automatic Datadog span bridging. +func newDDAwareLogger(underlying log.Logger) log.Logger { + return &ddAwareLogger{underlying: underlying} +} diff --git a/ddtrace/opentelemetry/log/logger_provider.go b/ddtrace/opentelemetry/log/logger_provider.go new file mode 100644 index 0000000000..b7f2c29fd4 --- /dev/null +++ b/ddtrace/opentelemetry/log/logger_provider.go @@ -0,0 +1,145 @@ +// 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 log + +import ( + "context" + "sync" + + "github.com/DataDog/dd-trace-go/v2/internal/log" + + otellog "go.opentelemetry.io/otel/log" + "go.opentelemetry.io/otel/log/embedded" + sdklog "go.opentelemetry.io/otel/sdk/log" +) + +var ( + // globalLoggerProvider holds the singleton LoggerProvider instance + globalLoggerProvider *sdklog.LoggerProvider + globalLoggerProviderWrapper otellog.LoggerProvider + globalLoggerProviderOnce sync.Once + globalLoggerProviderMu sync.Mutex +) + +// InitGlobalLoggerProvider initializes a global OTel LoggerProvider +// configured with Datadog-specific defaults. +// +// It creates: +// - A resource with Datadog and OTEL resource attributes (with DD precedence) +// - A BatchLogRecordProcessor configured with BLRP environment variables +// - An OTLP exporter (HTTP or gRPC) configured with DD agent endpoint resolution +// +// The LoggerProvider can be accessed via GetGlobalLoggerProvider() and should be +// used by OTel log bridges and logging integrations. +// +// This function is idempotent - calling it multiple times will return the same +// LoggerProvider instance. To create a new LoggerProvider, call ShutdownGlobalLoggerProvider +// first. +// +// Returns an error if LoggerProvider creation fails. +func InitGlobalLoggerProvider(ctx context.Context) error { + var err error + globalLoggerProviderOnce.Do(func() { + globalLoggerProviderMu.Lock() + defer globalLoggerProviderMu.Unlock() + + // Create resource with Datadog precedence + resource, resourceErr := buildResource(ctx) + if resourceErr != nil { + err = resourceErr + log.Error("Failed to build resource for LoggerProvider: %v", resourceErr) + return + } + + // Create OTLP exporter (HTTP or gRPC based on protocol) + exporter, exporterErr := newOTLPExporter(ctx, nil, nil) + if exporterErr != nil { + err = exporterErr + log.Error("Failed to create OTLP exporter for LoggerProvider: %v", exporterErr) + return + } + + // Create BatchLogRecordProcessor with BLRP environment variables + processor := sdklog.NewBatchProcessor( + exporter, + sdklog.WithMaxQueueSize(resolveBLRPMaxQueueSize()), + sdklog.WithExportInterval(resolveBLRPScheduleDelay()), + sdklog.WithExportTimeout(resolveBLRPExportTimeout()), + sdklog.WithExportMaxBatchSize(resolveBLRPMaxExportBatchSize()), + ) + + // Create LoggerProvider with resource and processor + globalLoggerProvider = sdklog.NewLoggerProvider( + sdklog.WithResource(resource), + sdklog.WithProcessor(processor), + ) + + // Create the DD-aware wrapper + globalLoggerProviderWrapper = &ddAwareLoggerProvider{underlying: globalLoggerProvider} + + log.Debug("OTel LoggerProvider initialized") + }) + + return err +} + +// ShutdownGlobalLoggerProvider shuts down the global LoggerProvider if it exists. +// This flushes any pending log records and cleans up resources. +// +// This function is safe to call multiple times and is idempotent. +// After shutdown, InitGlobalLoggerProvider can be called again to create a new instance. +// +// The ctx parameter can be used to set a deadline for the shutdown operation. +// If the context is canceled or times out, shutdown will abort but still mark +// the provider as shut down. +func ShutdownGlobalLoggerProvider(ctx context.Context) error { + globalLoggerProviderMu.Lock() + defer globalLoggerProviderMu.Unlock() + + if globalLoggerProvider == nil { + return nil + } + + log.Debug("Shutting down OTel LoggerProvider") + err := globalLoggerProvider.Shutdown(ctx) + if err != nil { + log.Warn("Error shutting down LoggerProvider: %v", err) + } + + // Reset the singleton state so it can be reinitialized + globalLoggerProvider = nil + globalLoggerProviderWrapper = nil + globalLoggerProviderOnce = sync.Once{} + + return err +} + +// GetGlobalLoggerProvider returns the global LoggerProvider instance if it has been initialized. +// Returns nil if InitGlobalLoggerProvider has not been called yet. +// +// This LoggerProvider should be used by OTel log bridges and logging integrations +// to emit logs to the Datadog Agent via OTLP. +// +// The returned LoggerProvider automatically bridges DD spans to OTel context for +// proper trace/span correlation. +func GetGlobalLoggerProvider() otellog.LoggerProvider { + globalLoggerProviderMu.Lock() + defer globalLoggerProviderMu.Unlock() + return globalLoggerProviderWrapper +} + +// ddAwareLoggerProvider wraps a LoggerProvider to return DD-aware loggers +// that automatically bridge DD spans to OTel context. +type ddAwareLoggerProvider struct { + embedded.LoggerProvider + underlying *sdklog.LoggerProvider +} + +// Logger returns a DD-aware logger that automatically bridges DD spans. +func (p *ddAwareLoggerProvider) Logger(name string, options ...otellog.LoggerOption) otellog.Logger { + underlying := p.underlying.Logger(name, options...) + return newDDAwareLogger(underlying) +} diff --git a/ddtrace/opentelemetry/log/resource.go b/ddtrace/opentelemetry/log/resource.go new file mode 100644 index 0000000000..060e18792b --- /dev/null +++ b/ddtrace/opentelemetry/log/resource.go @@ -0,0 +1,168 @@ +// 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 log + +import ( + "context" + "os" + + "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" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/sdk/resource" + semconv "go.opentelemetry.io/otel/semconv/v1.34.0" +) + +const ( + // DD environment variable names + envDDService = "DD_SERVICE" + envDDEnv = "DD_ENV" + envDDVersion = "DD_VERSION" + envDDTags = "DD_TAGS" + envDDHostname = "DD_HOSTNAME" + envDDTraceReportHostname = "DD_TRACE_REPORT_HOSTNAME" + + // OTel environment variable names + envOtelResourceAttributes = "OTEL_RESOURCE_ATTRIBUTES" +) + +// buildResource creates an OpenTelemetry resource for logs with Datadog-specific attributes. +// +// Precedence rule (critical): Datadog settings win over OTEL_RESOURCE_ATTRIBUTES +// Implementation: +// 1. Parse OTEL_RESOURCE_ATTRIBUTES into a map first (base layer) +// 2. Overlay Datadog-derived attributes on top (overwrite conflicts): +// - DD_SERVICE → service.name +// - DD_ENV → deployment.environment +// - DD_VERSION → service.version +// - DD_TAGS → convert k:v pairs into resource attributes +// +// Hostname rule: +// - host.name is only set if explicitly provided by: +// - DD_HOSTNAME, or +// - DD_TRACE_REPORT_HOSTNAME=true (uses DD_HOSTNAME or detected hostname), or +// - OTEL_RESOURCE_ATTRIBUTES already includes it +// +// - Datadog hostname takes precedence over OTEL hostname if both are present +func buildResource(ctx context.Context, opts ...resource.Option) (*resource.Resource, error) { + // Step 1: Parse OTEL_RESOURCE_ATTRIBUTES as base layer + otelAttrs := make(map[string]string) + if otelAttrStr := env.Get(envOtelResourceAttributes); otelAttrStr != "" { + otelAttrs = parseOtelResourceAttributes(otelAttrStr) + } + + // Step 2: Parse DD_TAGS + ddTags := make(map[string]string) + if ddTagsStr := env.Get(envDDTags); ddTagsStr != "" { + ddTags = internal.ParseTagString(ddTagsStr) + } + + // Step 3: Overlay Datadog attributes (these win over OTEL) + // Start with OTEL attributes as base + attrs := make(map[string]string) + for k, v := range otelAttrs { + attrs[k] = v + } + + // Overlay DD_SERVICE → service.name + if ddService := env.Get(envDDService); ddService != "" { + attrs["service.name"] = ddService + } + + // Overlay DD_ENV → deployment.environment + if ddEnv := env.Get(envDDEnv); ddEnv != "" { + attrs["deployment.environment"] = ddEnv + } + + // Overlay DD_VERSION → service.version + if ddVersion := env.Get(envDDVersion); ddVersion != "" { + attrs["service.version"] = ddVersion + } + + // Overlay DD_TAGS (all key-value pairs) + for k, v := range ddTags { + attrs[k] = v + } + + // Step 4: Handle hostname with special rules + hostname, shouldAddHostname := resolveHostname() + if shouldAddHostname && hostname != "" { + attrs["host.name"] = hostname + } + + // Step 5: Convert map to attribute.KeyValue slice + keyValues := make([]attribute.KeyValue, 0, len(attrs)) + for k, v := range attrs { + // Map known semantic convention keys + switch k { + case "service.name": + keyValues = append(keyValues, semconv.ServiceName(v)) + case "deployment.environment": + keyValues = append(keyValues, semconv.DeploymentEnvironmentNameKey.String(v)) + case "service.version": + keyValues = append(keyValues, semconv.ServiceVersion(v)) + case "host.name": + keyValues = append(keyValues, semconv.HostName(v)) + default: + // All other attributes as-is + keyValues = append(keyValues, attribute.String(k, v)) + } + } + + // Merge with any user-provided resource options + opts = append(opts, resource.WithAttributes(keyValues...)) + + // Always include telemetry SDK info + opts = append(opts, resource.WithTelemetrySDK()) + + // Create the resource + return resource.New(ctx, opts...) +} + +// resolveHostname determines the hostname value and whether it should be included. +// Returns (hostname, shouldAdd) where: +// - hostname: the resolved hostname value +// - shouldAdd: true if hostname should be added to resource, false otherwise +// +// Hostname is only added if explicitly provided by: +// 1. DD_HOSTNAME is set, or +// 2. DD_TRACE_REPORT_HOSTNAME=true (uses DD_HOSTNAME or detected hostname), or +// 3. OTEL_RESOURCE_ATTRIBUTES already includes host.name (checked by caller) +// +// Note: OTEL_RESOURCE_ATTRIBUTES[host.name] is handled in the main function +// through the normal overlay process, so we only need to check DD settings here. +func resolveHostname() (string, bool) { + // Check DD_HOSTNAME first - if explicitly set, always use it + if ddHostname := env.Get(envDDHostname); ddHostname != "" { + return ddHostname, true + } + + // Check if DD_TRACE_REPORT_HOSTNAME is set to "true" + if reportHostname := env.Get(envDDTraceReportHostname); reportHostname == "true" { + // Try to detect hostname + if hostname, err := os.Hostname(); err == nil && hostname != "" { + return hostname, true + } else if err != nil { + log.Warn("unable to look up hostname: %s", err.Error()) + } + } + + // Hostname should not be added + return "", false +} + +// parseOtelResourceAttributes parses OTEL_RESOURCE_ATTRIBUTES string into a map. +// Format: key1=value1,key2=value2 +// Invalid entries are silently ignored (best-effort parsing). +func parseOtelResourceAttributes(str string) map[string]string { + res := make(map[string]string) + internal.ForEachStringTag(str, internal.OtelTagsDelimeter, func(key, val string) { + res[key] = val + }) + return res +} diff --git a/go.mod b/go.mod index ebfdd97a53..782458e369 100644 --- a/go.mod +++ b/go.mod @@ -33,10 +33,14 @@ require ( go.opentelemetry.io/collector/pdata/pprofile v0.140.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 go.opentelemetry.io/otel v1.38.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.13.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.13.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 + go.opentelemetry.io/otel/log v0.13.0 go.opentelemetry.io/otel/metric v1.38.0 go.opentelemetry.io/otel/sdk v1.38.0 + go.opentelemetry.io/otel/sdk/log v0.13.0 go.opentelemetry.io/otel/sdk/metric v1.38.0 go.opentelemetry.io/otel/trace v1.38.0 go.uber.org/goleak v1.3.0 @@ -94,7 +98,6 @@ require ( go.opentelemetry.io/collector/internal/telemetry v0.133.0 // indirect go.opentelemetry.io/collector/pdata v1.46.0 // indirect go.opentelemetry.io/contrib/bridges/otelzap v0.12.0 // indirect - go.opentelemetry.io/otel/log v0.13.0 // indirect go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/mock v0.6.0 // indirect diff --git a/go.sum b/go.sum index b0b20860aa..0a910329a0 100644 --- a/go.sum +++ b/go.sum @@ -210,6 +210,10 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.13.0 h1:z6lNIajgEBVtQZHjfw2hAccPEBDs+nx58VemmXWa2ec= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.13.0/go.mod h1:+kyc3bRx/Qkq05P6OCu3mTEIOxYRYzoIg+JsUp5X+PM= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.13.0 h1:zUfYw8cscHHLwaY8Xz3fiJu+R59xBnkgq2Zr1lwmK/0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.13.0/go.mod h1:514JLMCcFLQFS8cnTepOk6I09cKWJ5nGHBxHrMJ8Yfg= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 h1:zG8GlgXCJQd5BU98C0hZnBbElszTmUgCNCfYneaDL0A= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0/go.mod h1:hOfBCz8kv/wuq73Mx2H2QnWokh/kHZxkh6SNF2bdKtw= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 h1:Oe2z/BCg5q7k4iXC3cqJxKYg0ieRiOqF0cecFYdPTwk= @@ -222,6 +226,10 @@ go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgf go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/log v0.13.0 h1:I3CGUszjM926OphK8ZdzF+kLqFvfRY/IIoFq/TjwfaQ= +go.opentelemetry.io/otel/sdk/log v0.13.0/go.mod h1:lOrQyCCXmpZdN7NchXb6DOZZa1N5G1R2tm5GMMTpDBw= +go.opentelemetry.io/otel/sdk/log/logtest v0.13.0 h1:9yio6AFZ3QD9j9oqshV1Ibm9gPLlHNxurno5BreMtIA= +go.opentelemetry.io/otel/sdk/log/logtest v0.13.0/go.mod h1:QOGiAJHl+fob8Nu85ifXfuQYmJTFAvcrxL6w5/tu168= go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= diff --git a/internal/config/config.go b/internal/config/config.go index ad645f5faf..9e62274c60 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -101,6 +101,8 @@ type Config struct { featureFlags map[string]struct{} // retryInterval is the interval between agent connection retries. It has no effect if sendRetries is not set retryInterval time.Duration + // logsOtelEnabled controls if the OpenTelemetry Logs SDK pipeline should be enabled + logsOtelEnabled bool } // HOT PATH NOTE: @@ -145,6 +147,7 @@ func loadConfig() *Config { cfg.globalSampleRate = provider.getFloatWithValidator("DD_TRACE_SAMPLE_RATE", math.NaN(), validateSampleRate) cfg.debugStack = provider.getBool("DD_TRACE_DEBUG_STACK", true) cfg.retryInterval = provider.getDuration("DD_TRACE_RETRY_INTERVAL", time.Millisecond) + cfg.logsOtelEnabled = provider.getBool("DD_LOGS_OTEL_ENABLED", false) // Parse feature flags from DD_TRACE_FEATURES as a set cfg.featureFlags = make(map[string]struct{}) @@ -615,3 +618,16 @@ func (c *Config) SetCIVisibilityEnabled(enabled bool, origin telemetry.Origin) { c.ciVisibilityEnabled = enabled reportTelemetry(constants.CIVisibilityEnabledEnvironmentVariable, enabled, origin) } + +func (c *Config) LogsOtelEnabled() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.logsOtelEnabled +} + +func (c *Config) SetLogsOtelEnabled(enabled bool, origin telemetry.Origin) { + c.mu.Lock() + defer c.mu.Unlock() + c.logsOtelEnabled = enabled + reportTelemetry("DD_LOGS_OTEL_ENABLED", enabled, origin) +} diff --git a/internal/env/supported_configurations.gen.go b/internal/env/supported_configurations.gen.go index 5e5a802da0..ebf95775b4 100644 --- a/internal/env/supported_configurations.gen.go +++ b/internal/env/supported_configurations.gen.go @@ -84,6 +84,7 @@ var SupportedConfigurations = map[string]struct{}{ "DD_LLMOBS_ML_APP": {}, "DD_LLMOBS_PROJECT_NAME": {}, "DD_LOGGING_RATE": {}, + "DD_LOGS_OTEL_ENABLED": {}, "DD_METRICS_OTEL_ENABLED": {}, "DD_PIPELINE_EXECUTION_ID": {}, "DD_PROFILING_AGENTLESS": {}, @@ -227,13 +228,23 @@ var SupportedConfigurations = map[string]struct{}{ "DD_TRACE_X_DATADOG_TAGS_MAX_LENGTH": {}, "DD_TRACE__ANALYTICS_ENABLED": {}, "DD_VERSION": {}, + "OTEL_BLRP_EXPORT_TIMEOUT": {}, + "OTEL_BLRP_MAX_EXPORT_BATCH_SIZE": {}, + "OTEL_BLRP_MAX_QUEUE_SIZE": {}, + "OTEL_BLRP_SCHEDULE_DELAY": {}, "OTEL_EXPORTER_OTLP_ENDPOINT": {}, + "OTEL_EXPORTER_OTLP_HEADERS": {}, + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT": {}, + "OTEL_EXPORTER_OTLP_LOGS_HEADERS": {}, + "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL": {}, + "OTEL_EXPORTER_OTLP_LOGS_TIMEOUT": {}, "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT": {}, "OTEL_EXPORTER_OTLP_METRICS_HEADERS": {}, "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL": {}, "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE": {}, "OTEL_EXPORTER_OTLP_METRICS_TIMEOUT": {}, "OTEL_EXPORTER_OTLP_PROTOCOL": {}, + "OTEL_EXPORTER_OTLP_TIMEOUT": {}, "OTEL_LOGS_EXPORTER": {}, "OTEL_LOG_LEVEL": {}, "OTEL_METRICS_EXPORTER": {}, diff --git a/internal/env/supported_configurations.json b/internal/env/supported_configurations.json index c53a69ab35..bc51d9f392 100644 --- a/internal/env/supported_configurations.json +++ b/internal/env/supported_configurations.json @@ -225,6 +225,9 @@ "DD_LOGGING_RATE": [ "A" ], + "DD_LOGS_OTEL_ENABLED": [ + "A" + ], "DD_METRICS_OTEL_ENABLED": [ "A" ], @@ -420,10 +423,10 @@ "DD_TRACE_DEBUG_ABANDONED_SPANS": [ "A" ], - "DD_TRACE_DEBUG_STACK": [ + "DD_TRACE_DEBUG_SEELOG_WORKAROUND": [ "A" ], - "DD_TRACE_DEBUG_SEELOG_WORKAROUND": [ + "DD_TRACE_DEBUG_STACK": [ "A" ], "DD_TRACE_ECHO_ANALYTICS_ENABLED": [ @@ -654,9 +657,36 @@ "DD_VERSION": [ "A" ], + "OTEL_BLRP_EXPORT_TIMEOUT": [ + "A" + ], + "OTEL_BLRP_MAX_EXPORT_BATCH_SIZE": [ + "A" + ], + "OTEL_BLRP_MAX_QUEUE_SIZE": [ + "A" + ], + "OTEL_BLRP_SCHEDULE_DELAY": [ + "A" + ], "OTEL_EXPORTER_OTLP_ENDPOINT": [ "A" ], + "OTEL_EXPORTER_OTLP_HEADERS": [ + "A" + ], + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT": [ + "A" + ], + "OTEL_EXPORTER_OTLP_LOGS_HEADERS": [ + "A" + ], + "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL": [ + "A" + ], + "OTEL_EXPORTER_OTLP_LOGS_TIMEOUT": [ + "A" + ], "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT": [ "A" ], @@ -675,6 +705,9 @@ "OTEL_EXPORTER_OTLP_PROTOCOL": [ "A" ], + "OTEL_EXPORTER_OTLP_TIMEOUT": [ + "A" + ], "OTEL_LOGS_EXPORTER": [ "A" ], From 4326458f1e31be4d3927488a4fb6d9a6d05e98da Mon Sep 17 00:00:00 2001 From: Rachel Yang Date: Tue, 13 Jan 2026 16:29:50 -0500 Subject: [PATCH 2/7] generate new files --- internal/stacktrace/contribs_generated.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/stacktrace/contribs_generated.go b/internal/stacktrace/contribs_generated.go index 8b1cc83f6e..f4be610674 100644 --- a/internal/stacktrace/contribs_generated.go +++ b/internal/stacktrace/contribs_generated.go @@ -723,6 +723,8 @@ func generatedThirdPartyLibraries() []string { "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp", "go.opentelemetry.io/contrib/otelconf", "go.opentelemetry.io/otel", + "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc", + "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp", "go.opentelemetry.io/otel/exporters/otlp/otlpmetric", "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc", "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp", @@ -734,6 +736,7 @@ func generatedThirdPartyLibraries() []string { "go.opentelemetry.io/otel/log/logtest", "go.opentelemetry.io/otel/metric", "go.opentelemetry.io/otel/sdk", + "go.opentelemetry.io/otel/sdk/log", "go.opentelemetry.io/otel/sdk/metric", "go.opentelemetry.io/otel/trace", "go.opentelemetry.io/proto/otlp", From 94efc1350813d6086873f9054ec9c4bd72e4fd22 Mon Sep 17 00:00:00 2001 From: Rachel Yang Date: Tue, 13 Jan 2026 17:37:53 -0500 Subject: [PATCH 3/7] hostname fix --- ddtrace/opentelemetry/log/resource.go | 48 ++++++++++++++++----------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/ddtrace/opentelemetry/log/resource.go b/ddtrace/opentelemetry/log/resource.go index 060e18792b..9542f86461 100644 --- a/ddtrace/opentelemetry/log/resource.go +++ b/ddtrace/opentelemetry/log/resource.go @@ -90,9 +90,13 @@ func buildResource(ctx context.Context, opts ...resource.Option) (*resource.Reso } // Step 4: Handle hostname with special rules - hostname, shouldAddHostname := resolveHostname() - if shouldAddHostname && hostname != "" { - attrs["host.name"] = hostname + // OTEL_RESOURCE_ATTRIBUTES[host.name] has highest priority - never override it + if _, hasOtelHostname := otelAttrs["host.name"]; !hasOtelHostname { + // OTEL didn't set hostname, so check DD settings + hostname, shouldAddHostname := resolveHostname() + if shouldAddHostname && hostname != "" { + attrs["host.name"] = hostname + } } // Step 5: Convert map to attribute.KeyValue slice @@ -129,30 +133,36 @@ func buildResource(ctx context.Context, opts ...resource.Option) (*resource.Reso // - hostname: the resolved hostname value // - shouldAdd: true if hostname should be added to resource, false otherwise // -// Hostname is only added if explicitly provided by: -// 1. DD_HOSTNAME is set, or -// 2. DD_TRACE_REPORT_HOSTNAME=true (uses DD_HOSTNAME or detected hostname), or -// 3. OTEL_RESOURCE_ATTRIBUTES already includes host.name (checked by caller) +// Hostname is ONLY added if: +// 1. DD_TRACE_REPORT_HOSTNAME=true (uses DD_HOSTNAME or detected hostname), OR +// 2. OTEL_RESOURCE_ATTRIBUTES already includes host.name (handled by caller) // -// Note: OTEL_RESOURCE_ATTRIBUTES[host.name] is handled in the main function -// through the normal overlay process, so we only need to check DD settings here. +// Just setting DD_HOSTNAME alone does NOT add hostname - it needs DD_TRACE_REPORT_HOSTNAME=true. +// This ensures hostname is only sent when explicitly enabled (privacy by default). func resolveHostname() (string, bool) { - // Check DD_HOSTNAME first - if explicitly set, always use it + // Check if DD_TRACE_REPORT_HOSTNAME is set to "true" + reportHostname := env.Get(envDDTraceReportHostname) + if reportHostname != "true" { + // Hostname reporting not enabled - do not add hostname + return "", false + } + + // DD_TRACE_REPORT_HOSTNAME=true, so we should add hostname + // Priority: DD_HOSTNAME → detected hostname + + // Check DD_HOSTNAME first if ddHostname := env.Get(envDDHostname); ddHostname != "" { return ddHostname, true } - // Check if DD_TRACE_REPORT_HOSTNAME is set to "true" - if reportHostname := env.Get(envDDTraceReportHostname); reportHostname == "true" { - // Try to detect hostname - if hostname, err := os.Hostname(); err == nil && hostname != "" { - return hostname, true - } else if err != nil { - log.Warn("unable to look up hostname: %s", err.Error()) - } + // Try to detect hostname + if hostname, err := os.Hostname(); err == nil && hostname != "" { + return hostname, true + } else if err != nil { + log.Warn("unable to look up hostname: %s", err.Error()) } - // Hostname should not be added + // Could not determine hostname return "", false } From 02e03c384bfb7834791d2c82eb3bc9d428de3ea0 Mon Sep 17 00:00:00 2001 From: Rachel Yang Date: Wed, 14 Jan 2026 15:47:32 -0500 Subject: [PATCH 4/7] add telemetry metrics --- ddtrace/opentelemetry/log/exporter.go | 60 ++++- ddtrace/opentelemetry/log/logger_provider.go | 3 + ddtrace/opentelemetry/log/telemetry.go | 237 +++++++++++++++++++ 3 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 ddtrace/opentelemetry/log/telemetry.go diff --git a/ddtrace/opentelemetry/log/exporter.go b/ddtrace/opentelemetry/log/exporter.go index 0b7379f609..a3d22eed0f 100644 --- a/ddtrace/opentelemetry/log/exporter.go +++ b/ddtrace/opentelemetry/log/exporter.go @@ -57,8 +57,36 @@ const ( defaultBLRPScheduleDelay = 1000 * time.Millisecond defaultBLRPExportTimeout = 30000 * time.Millisecond defaultBLRPMaxExportBatchSize = 512 + + // Default values for BatchLogRecordProcessor in milliseconds (for telemetry reporting) + defaultBLRPScheduleDelayMs = 1000 + defaultBLRPExportTimeoutMs = 30000 + defaultOTLPTimeoutMs = 10000 // 10 seconds + + // Protocol and encoding constants for telemetry tagging + protocolHTTP = "http" + protocolGRPC = "grpc" + encodingJSON = "json" + encodingProtobuf = "protobuf" ) +// telemetryExporter wraps an sdklog.Exporter to track log record exports. +type telemetryExporter struct { + sdklog.Exporter + telemetry *LogsExportTelemetry +} + +// Export implements sdklog.Exporter. +func (e *telemetryExporter) Export(ctx context.Context, records []sdklog.Record) error { + err := e.Exporter.Export(ctx, records) + // Record the number of log records exported (success or failure) + // This matches the RFC requirement to track log_records counter + if len(records) > 0 { + e.telemetry.RecordLogRecords(len(records)) + } + return err +} + // newOTLPExporter creates an OTLP exporter (HTTP or gRPC) configured with Datadog-specific defaults. // // Protocol selection priority: @@ -81,15 +109,39 @@ func newOTLPExporter(ctx context.Context, httpOpts []otlploghttp.Option, grpcOpt // Determine protocol protocol := resolveOTLPProtocol() + var exporter sdklog.Exporter + var err error + var protocolTag, encodingTag string + switch protocol { case "grpc": - return newOTLPGRPCExporter(ctx, grpcOpts...) - case "http/json", "http/protobuf", "http": - return newOTLPHTTPExporter(ctx, httpOpts...) + exporter, err = newOTLPGRPCExporter(ctx, grpcOpts...) + protocolTag = protocolGRPC + encodingTag = encodingProtobuf + case "http/json": + exporter, err = newOTLPHTTPExporter(ctx, httpOpts...) + protocolTag = protocolHTTP + encodingTag = encodingJSON + case "http/protobuf", "http": + exporter, err = newOTLPHTTPExporter(ctx, httpOpts...) + protocolTag = protocolHTTP + encodingTag = encodingProtobuf default: log.Warn("Unknown OTLP logs protocol %q, defaulting to %s", protocol, defaultOTLPProtocol) - return newOTLPHTTPExporter(ctx, httpOpts...) + exporter, err = newOTLPHTTPExporter(ctx, httpOpts...) + protocolTag = protocolHTTP + encodingTag = encodingJSON + } + + if err != nil { + return nil, err } + + // Wrap the exporter with telemetry tracking + return &telemetryExporter{ + Exporter: exporter, + telemetry: NewLogsExportTelemetry(protocolTag, encodingTag), + }, nil } // resolveOTLPProtocol returns the OTLP protocol from environment variables. diff --git a/ddtrace/opentelemetry/log/logger_provider.go b/ddtrace/opentelemetry/log/logger_provider.go index b7f2c29fd4..3393932f30 100644 --- a/ddtrace/opentelemetry/log/logger_provider.go +++ b/ddtrace/opentelemetry/log/logger_provider.go @@ -80,6 +80,9 @@ func InitGlobalLoggerProvider(ctx context.Context) error { // Create the DD-aware wrapper globalLoggerProviderWrapper = &ddAwareLoggerProvider{underlying: globalLoggerProvider} + // Register telemetry configuration + registerTelemetry() + log.Debug("OTel LoggerProvider initialized") }) diff --git a/ddtrace/opentelemetry/log/telemetry.go b/ddtrace/opentelemetry/log/telemetry.go new file mode 100644 index 0000000000..86f8843aec --- /dev/null +++ b/ddtrace/opentelemetry/log/telemetry.go @@ -0,0 +1,237 @@ +// 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 log + +import ( + "cmp" + "strconv" + "strings" + + "github.com/DataDog/dd-trace-go/v2/internal/env" + "github.com/DataDog/dd-trace-go/v2/internal/telemetry" +) + +// Note: Environment variable constants are defined in exporter.go +// Note: Default millisecond values are defined in exporter.go + +// registerTelemetry reports OTel logs configuration to Datadog telemetry. +// This is called when the LoggerProvider is initialized and logs are enabled. +// +// Configuration telemetry includes: +// - Generic OTLP Exporter Configurations: OTEL_EXPORTER_OTLP_TIMEOUT, OTEL_EXPORTER_OTLP_HEADERS, +// OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_EXPORTER_OTLP_ENDPOINT +// - Logs-specific OTLP Exporter Configurations: OTEL_EXPORTER_OTLP_LOGS_TIMEOUT, +// OTEL_EXPORTER_OTLP_LOGS_HEADERS, OTEL_EXPORTER_OTLP_LOGS_PROTOCOL, OTEL_EXPORTER_OTLP_LOGS_ENDPOINT +// - BatchLogRecordProcessor Configurations: OTEL_BLRP_MAX_QUEUE_SIZE, OTEL_BLRP_SCHEDULE_DELAY, +// OTEL_BLRP_EXPORT_TIMEOUT, OTEL_BLRP_MAX_EXPORT_BATCH_SIZE +func registerTelemetry() { + telemetryConfigs := []telemetry.Configuration{} + + // =========================================== + // Generic OTLP Exporter Configurations + // (These apply to all signals, not just logs) + // =========================================== + + // OTEL_EXPORTER_OTLP_TIMEOUT + if timeout := env.Get(envOTLPTimeout); timeout != "" { + if ms, err := parseMilliseconds(timeout); err == nil { + telemetryConfigs = append(telemetryConfigs, telemetry.Configuration{ + Name: envOTLPTimeout, + Value: ms, + Origin: telemetry.OriginEnvVar, + }) + } + } + + // OTEL_EXPORTER_OTLP_HEADERS + if headers := env.Get(envOTLPHeaders); headers != "" { + telemetryConfigs = append(telemetryConfigs, telemetry.Configuration{ + Name: envOTLPHeaders, + Value: headers, + Origin: telemetry.OriginEnvVar, + }) + } + + // OTEL_EXPORTER_OTLP_PROTOCOL + if protocol := env.Get(envOTLPProtocol); protocol != "" { + telemetryConfigs = append(telemetryConfigs, telemetry.Configuration{ + Name: envOTLPProtocol, + Value: strings.ToLower(strings.TrimSpace(protocol)), + Origin: telemetry.OriginEnvVar, + }) + } + + // OTEL_EXPORTER_OTLP_ENDPOINT + if endpoint := env.Get(envOTLPEndpoint); endpoint != "" { + telemetryConfigs = append(telemetryConfigs, telemetry.Configuration{ + Name: envOTLPEndpoint, + Value: endpoint, + Origin: telemetry.OriginEnvVar, + }) + } + + // =========================================== + // Logs-specific OTLP Exporter Configurations + // =========================================== + + // OTEL_EXPORTER_OTLP_LOGS_TIMEOUT + logsTimeout := getMillisecondsConfig(envOTLPLogsTimeout, defaultOTLPTimeoutMs) + telemetryConfigs = append(telemetryConfigs, telemetry.Configuration{ + Name: envOTLPLogsTimeout, + Value: logsTimeout.value, + Origin: logsTimeout.origin, + }) + + // OTEL_EXPORTER_OTLP_LOGS_HEADERS + if headers := env.Get(envOTLPLogsHeaders); headers != "" { + telemetryConfigs = append(telemetryConfigs, telemetry.Configuration{ + Name: envOTLPLogsHeaders, + Value: headers, + Origin: telemetry.OriginEnvVar, + }) + } + + // OTEL_EXPORTER_OTLP_LOGS_PROTOCOL + if protocol := env.Get(envOTLPLogsProtocol); protocol != "" { + telemetryConfigs = append(telemetryConfigs, telemetry.Configuration{ + Name: envOTLPLogsProtocol, + Value: strings.ToLower(strings.TrimSpace(protocol)), + Origin: telemetry.OriginEnvVar, + }) + } + + // OTEL_EXPORTER_OTLP_LOGS_ENDPOINT + if endpoint := env.Get(envOTLPLogsEndpoint); endpoint != "" { + telemetryConfigs = append(telemetryConfigs, telemetry.Configuration{ + Name: envOTLPLogsEndpoint, + Value: endpoint, + Origin: telemetry.OriginEnvVar, + }) + } + + // =========================================== + // BatchLogRecordProcessor Configurations + // =========================================== + + // OTEL_BLRP_MAX_QUEUE_SIZE + maxQueueSize := getIntConfig(envBLRPMaxQueueSize, defaultBLRPMaxQueueSize) + telemetryConfigs = append(telemetryConfigs, telemetry.Configuration{ + Name: envBLRPMaxQueueSize, + Value: maxQueueSize.value, + Origin: maxQueueSize.origin, + }) + + // OTEL_BLRP_SCHEDULE_DELAY + scheduleDelay := getMillisecondsConfig(envBLRPScheduleDelay, defaultBLRPScheduleDelayMs) + telemetryConfigs = append(telemetryConfigs, telemetry.Configuration{ + Name: envBLRPScheduleDelay, + Value: scheduleDelay.value, + Origin: scheduleDelay.origin, + }) + + // OTEL_BLRP_EXPORT_TIMEOUT + exportTimeout := getMillisecondsConfig(envBLRPExportTimeout, defaultBLRPExportTimeoutMs) + telemetryConfigs = append(telemetryConfigs, telemetry.Configuration{ + Name: envBLRPExportTimeout, + Value: exportTimeout.value, + Origin: exportTimeout.origin, + }) + + // OTEL_BLRP_MAX_EXPORT_BATCH_SIZE + maxExportBatchSize := getIntConfig(envBLRPMaxExportBatchSize, defaultBLRPMaxExportBatchSize) + telemetryConfigs = append(telemetryConfigs, telemetry.Configuration{ + Name: envBLRPMaxExportBatchSize, + Value: maxExportBatchSize.value, + Origin: maxExportBatchSize.origin, + }) + + telemetry.RegisterAppConfigs(telemetryConfigs...) +} + +// parseMilliseconds parses a string value as milliseconds. +// The value can be a plain integer (milliseconds) or a duration string. +func parseMilliseconds(value string) (int, error) { + value = strings.TrimSpace(value) + + // Try parsing as integer (milliseconds) + if ms, err := strconv.Atoi(value); err == nil { + return ms, nil + } + + // Could add support for duration strings like "10s" here if needed + return 0, strconv.ErrSyntax +} + +// msConfig holds a milliseconds configuration value with its origin. +type msConfig struct { + value int + origin telemetry.Origin +} + +// parseMsFromEnv attempts to parse a milliseconds value from an environment variable. +// Returns a zero msConfig if the env var is empty or parsing fails. +func parseMsFromEnv(envVar string) msConfig { + if v := env.Get(envVar); v != "" { + if ms, err := parseMilliseconds(v); err == nil { + return msConfig{value: ms, origin: telemetry.OriginEnvVar} + } + } + return msConfig{} +} + +// parseIntFromEnv attempts to parse an integer value from an environment variable. +// Returns a zero msConfig if the env var is empty or parsing fails. +func parseIntFromEnv(envVar string) msConfig { + if v := env.Get(envVar); v != "" { + if val, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { + return msConfig{value: val, origin: telemetry.OriginEnvVar} + } + } + return msConfig{} +} + +// getMillisecondsConfig reads a milliseconds value from an environment variable, +// falling back to the provided default. Uses cmp.Or to select the first valid config. +func getMillisecondsConfig(envVar string, defaultMs int) msConfig { + return cmp.Or( + parseMsFromEnv(envVar), + msConfig{value: defaultMs, origin: telemetry.OriginDefault}, + ) +} + +// getIntConfig reads an integer value from an environment variable, +// falling back to the provided default. Uses cmp.Or to select the first valid config. +func getIntConfig(envVar string, defaultVal int) msConfig { + return cmp.Or( + parseIntFromEnv(envVar), + msConfig{value: defaultVal, origin: telemetry.OriginDefault}, + ) +} + +// LogsExportTelemetry provides telemetry metrics for OTLP logs export operations. +type LogsExportTelemetry struct { + logRecordsHandle telemetry.MetricHandle +} + +// NewLogsExportTelemetry creates a new LogsExportTelemetry for tracking log export operations. +// The protocol should be "http" or "grpc", and encoding should be "json" or "protobuf". +func NewLogsExportTelemetry(protocol, encoding string) *LogsExportTelemetry { + tags := []string{ + "protocol:" + protocol, + "encoding:" + encoding, + } + + return &LogsExportTelemetry{ + logRecordsHandle: telemetry.Count(telemetry.NamespaceGeneral, "otel.log_records", tags), + } +} + +// RecordLogRecords records the number of log records exported. +func (t *LogsExportTelemetry) RecordLogRecords(count int) { + if t != nil && t.logRecordsHandle != nil && count > 0 { + t.logRecordsHandle.Submit(float64(count)) + } +} From 85b524cef9b22381967006c524479ad153543665 Mon Sep 17 00:00:00 2001 From: Rachel Yang Date: Wed, 14 Jan 2026 16:02:01 -0500 Subject: [PATCH 5/7] fix attribute mapping --- ddtrace/opentelemetry/log/resource.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ddtrace/opentelemetry/log/resource.go b/ddtrace/opentelemetry/log/resource.go index 9542f86461..3a31061d8a 100644 --- a/ddtrace/opentelemetry/log/resource.go +++ b/ddtrace/opentelemetry/log/resource.go @@ -74,9 +74,9 @@ func buildResource(ctx context.Context, opts ...resource.Option) (*resource.Reso attrs["service.name"] = ddService } - // Overlay DD_ENV → deployment.environment + // Overlay DD_ENV → deployment.environment.name if ddEnv := env.Get(envDDEnv); ddEnv != "" { - attrs["deployment.environment"] = ddEnv + attrs["deployment.environment.name"] = ddEnv } // Overlay DD_VERSION → service.version @@ -106,7 +106,7 @@ func buildResource(ctx context.Context, opts ...resource.Option) (*resource.Reso switch k { case "service.name": keyValues = append(keyValues, semconv.ServiceName(v)) - case "deployment.environment": + case "deployment.environment.name": keyValues = append(keyValues, semconv.DeploymentEnvironmentNameKey.String(v)) case "service.version": keyValues = append(keyValues, semconv.ServiceVersion(v)) From ba8d7aa96c7bcb952188426f309e0167f7530c31 Mon Sep 17 00:00:00 2001 From: Rachel Yang Date: Thu, 15 Jan 2026 16:37:53 -0500 Subject: [PATCH 6/7] fix telemetry reporting --- ddtrace/opentelemetry/log/telemetry.go | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/ddtrace/opentelemetry/log/telemetry.go b/ddtrace/opentelemetry/log/telemetry.go index 86f8843aec..5698f342ea 100644 --- a/ddtrace/opentelemetry/log/telemetry.go +++ b/ddtrace/opentelemetry/log/telemetry.go @@ -36,15 +36,13 @@ func registerTelemetry() { // =========================================== // OTEL_EXPORTER_OTLP_TIMEOUT - if timeout := env.Get(envOTLPTimeout); timeout != "" { - if ms, err := parseMilliseconds(timeout); err == nil { - telemetryConfigs = append(telemetryConfigs, telemetry.Configuration{ - Name: envOTLPTimeout, - Value: ms, - Origin: telemetry.OriginEnvVar, - }) - } - } + // Always report this (with default) since it's used as fallback for logs timeout + genericTimeout := getMillisecondsConfig(envOTLPTimeout, defaultOTLPTimeoutMs) + telemetryConfigs = append(telemetryConfigs, telemetry.Configuration{ + Name: envOTLPTimeout, + Value: genericTimeout.value, + Origin: genericTimeout.origin, + }) // OTEL_EXPORTER_OTLP_HEADERS if headers := env.Get(envOTLPHeaders); headers != "" { From 7743f0c0c877b8325c04ad18c3ff48878a3f21e5 Mon Sep 17 00:00:00 2001 From: Rachel Yang Date: Mon, 26 Jan 2026 13:34:50 -0500 Subject: [PATCH 7/7] adding tests --- ddtrace/opentelemetry/log/correlation_test.go | 382 ++++++++++++++++ ddtrace/opentelemetry/log/exporter_test.go | 386 +++++++++++++++++ ddtrace/opentelemetry/log/integration_test.go | 173 ++++++++ .../opentelemetry/log/logger_provider_test.go | 201 +++++++++ ddtrace/opentelemetry/log/resource_test.go | 408 ++++++++++++++++++ ddtrace/opentelemetry/log/telemetry_test.go | 254 +++++++++++ ddtrace/opentelemetry/log/test_exporter.go | 76 ++++ 7 files changed, 1880 insertions(+) create mode 100644 ddtrace/opentelemetry/log/correlation_test.go create mode 100644 ddtrace/opentelemetry/log/exporter_test.go create mode 100644 ddtrace/opentelemetry/log/integration_test.go create mode 100644 ddtrace/opentelemetry/log/logger_provider_test.go create mode 100644 ddtrace/opentelemetry/log/resource_test.go create mode 100644 ddtrace/opentelemetry/log/telemetry_test.go create mode 100644 ddtrace/opentelemetry/log/test_exporter.go diff --git a/ddtrace/opentelemetry/log/correlation_test.go b/ddtrace/opentelemetry/log/correlation_test.go new file mode 100644 index 0000000000..16f41c23ea --- /dev/null +++ b/ddtrace/opentelemetry/log/correlation_test.go @@ -0,0 +1,382 @@ +// 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 log + +import ( + "context" + "testing" + + "github.com/DataDog/dd-trace-go/v2/ddtrace/mocktracer" + "github.com/DataDog/dd-trace-go/v2/ddtrace/opentelemetry" + "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.opentelemetry.io/otel/log" + sdklog "go.opentelemetry.io/otel/sdk/log" + oteltrace "go.opentelemetry.io/otel/trace" +) + +func TestDDSpanContextToOtel(t *testing.T) { + mt := mocktracer.Start() + defer mt.Stop() + + // Start a DD span + span := tracer.StartSpan("test") + defer span.Finish() + + ddCtx := span.Context() + + // Convert to OTel span context + otelCtx := ddSpanContextToOtel(ddCtx) + + // Verify it's valid + assert.True(t, otelCtx.IsValid()) + + // Verify trace ID matches + expectedTraceID := ddCtx.TraceIDBytes() + actualTraceID := otelCtx.TraceID() + assert.Equal(t, expectedTraceID[:], actualTraceID[:]) + + // Verify span ID matches + spanIDBytes := make([]byte, 8) + for i := 0; i < 8; i++ { + spanIDBytes[i] = byte(ddCtx.SpanID() >> (56 - 8*i)) + } + var expectedSpanID [8]byte + copy(expectedSpanID[:], spanIDBytes) + actualSpanID := otelCtx.SpanID() + assert.Equal(t, expectedSpanID[:], actualSpanID[:]) + + // Verify it's marked as sampled + assert.True(t, otelCtx.IsSampled()) +} + +func TestContextWithDDSpan(t *testing.T) { + t.Run("adds OTel span wrapper when only DD span present", func(t *testing.T) { + mt := mocktracer.Start() + defer mt.Stop() + + // Start a DD span and add to context + ddSpan := tracer.StartSpan("test") + defer ddSpan.Finish() + ctx := tracer.ContextWithSpan(context.Background(), ddSpan) + + // Verify DD span is in context + retrievedDD, ok := tracer.SpanFromContext(ctx) + require.True(t, ok) + assert.Equal(t, ddSpan, retrievedDD) + + // Verify OTel span is NOT in context initially + otelSpan := oteltrace.SpanFromContext(ctx) + assert.False(t, otelSpan.SpanContext().IsValid()) + + // Bridge the DD span + bridgedCtx := contextWithDDSpan(ctx) + + // Now OTel span should be present + otelSpan = oteltrace.SpanFromContext(bridgedCtx) + assert.True(t, otelSpan.SpanContext().IsValid()) + + // Verify IDs match + expectedTID := ddSpan.Context().TraceIDBytes() + actualTID := otelSpan.SpanContext().TraceID() + assert.Equal(t, expectedTID[:], actualTID[:]) + }) + + t.Run("preserves existing OTel span", func(t *testing.T) { + // Create OTel tracer + provider := opentelemetry.NewTracerProvider() + otelTracer := provider.Tracer("test") + + // Start OTel span + ctx, otelSpan := otelTracer.Start(context.Background(), "test") + defer otelSpan.End() + + originalSpanCtx := otelSpan.SpanContext() + + // Bridge should preserve the OTel span + bridgedCtx := contextWithDDSpan(ctx) + + retrievedSpan := oteltrace.SpanFromContext(bridgedCtx) + assert.Equal(t, originalSpanCtx, retrievedSpan.SpanContext()) + }) + + t.Run("returns original context when no spans present", func(t *testing.T) { + ctx := context.Background() + bridgedCtx := contextWithDDSpan(ctx) + assert.Equal(t, ctx, bridgedCtx) + }) +} + +func TestLogCorrelation(t *testing.T) { + t.Run("DD span IDs appear in exported logs", func(t *testing.T) { + mt := mocktracer.Start() + defer mt.Stop() + + // Create a test exporter to capture logs + exporter := newTestExporter() + + // Create LoggerProvider with test exporter + resource, err := buildResource(context.Background()) + require.NoError(t, err) + + processor := sdklog.NewSimpleProcessor(exporter) + provider := sdklog.NewLoggerProvider( + sdklog.WithResource(resource), + sdklog.WithProcessor(processor), + ) + defer func() { + _ = provider.Shutdown(context.Background()) + }() + + // Wrap with DD-aware provider + ddProvider := &ddAwareLoggerProvider{underlying: provider} + + // Get a logger (DD-aware) + logger := ddProvider.Logger("test") + + // Start a DD span + ddSpan := tracer.StartSpan("test-operation") + defer ddSpan.Finish() + + ddCtx := ddSpan.Context() + ctx := tracer.ContextWithSpan(context.Background(), ddSpan) + + // NO explicit bridge needed - the DD-aware logger handles it automatically + + // Emit a log with the context + var logRecord log.Record + logRecord.SetBody(log.StringValue("test log message")) + logRecord.SetSeverity(log.SeverityInfo) + + logger.Emit(ctx, logRecord) + + // Force flush + err = provider.ForceFlush(context.Background()) + require.NoError(t, err) + + // Get exported records + records := exporter.GetRecords() + require.Len(t, records, 1) + + exportedRecord := records[0] + + // Verify trace ID matches + expectedTraceID := ddCtx.TraceIDBytes() + actualTraceID := exportedRecord.TraceID() + assert.Equal(t, expectedTraceID[:], actualTraceID[:]) + + // Verify span ID matches + spanIDBytes := make([]byte, 8) + for i := 0; i < 8; i++ { + spanIDBytes[i] = byte(ddCtx.SpanID() >> (56 - 8*i)) + } + var expectedSpanID [8]byte + copy(expectedSpanID[:], spanIDBytes) + actualSpanID := exportedRecord.SpanID() + assert.Equal(t, expectedSpanID[:], actualSpanID[:]) + + // Verify trace flags (sampled) + assert.Equal(t, byte(oteltrace.FlagsSampled), byte(exportedRecord.TraceFlags())) + }) + + t.Run("OTel span IDs appear in exported logs", func(t *testing.T) { + // Create OTel tracer + provider := opentelemetry.NewTracerProvider() + otelTracer := provider.Tracer("test") + + // Create a test exporter to capture logs + exporter := newTestExporter() + + // Create LoggerProvider with test exporter + resource, err := buildResource(context.Background()) + require.NoError(t, err) + + processor := sdklog.NewSimpleProcessor(exporter) + logProvider := sdklog.NewLoggerProvider( + sdklog.WithResource(resource), + sdklog.WithProcessor(processor), + ) + defer func() { + _ = logProvider.Shutdown(context.Background()) + }() + + // Wrap with DD-aware provider + ddProvider := &ddAwareLoggerProvider{underlying: logProvider} + + // Get a logger (DD-aware) + logger := ddProvider.Logger("test") + + // Start an OTel span + ctx, otelSpan := otelTracer.Start(context.Background(), "test-operation") + defer otelSpan.End() + + otelSpanCtx := otelSpan.SpanContext() + + // Emit a log with the context + var logRecord log.Record + logRecord.SetBody(log.StringValue("test log message from otel span")) + logRecord.SetSeverity(log.SeverityInfo) + + logger.Emit(ctx, logRecord) + + // Force flush + err = logProvider.ForceFlush(context.Background()) + require.NoError(t, err) + + // Get exported records + records := exporter.GetRecords() + require.Len(t, records, 1) + + exportedRecord := records[0] + + // Verify trace ID matches + expectedTID := otelSpanCtx.TraceID() + actualTID := exportedRecord.TraceID() + assert.Equal(t, expectedTID[:], actualTID[:]) + + // Verify span ID matches + expectedSID := otelSpanCtx.SpanID() + actualSID := exportedRecord.SpanID() + assert.Equal(t, expectedSID[:], actualSID[:]) + + // Verify trace flags + assert.Equal(t, byte(otelSpanCtx.TraceFlags()), byte(exportedRecord.TraceFlags())) + }) + + t.Run("logs without span have no trace context", func(t *testing.T) { + // Create a test exporter to capture logs + exporter := newTestExporter() + + // Create LoggerProvider with test exporter + resource, err := buildResource(context.Background()) + require.NoError(t, err) + + processor := sdklog.NewSimpleProcessor(exporter) + provider := sdklog.NewLoggerProvider( + sdklog.WithResource(resource), + sdklog.WithProcessor(processor), + ) + defer func() { + _ = provider.Shutdown(context.Background()) + }() + + // Wrap with DD-aware provider + ddProvider := &ddAwareLoggerProvider{underlying: provider} + + // Get a logger (DD-aware) + logger := ddProvider.Logger("test") + + // Emit a log WITHOUT any span context + ctx := context.Background() + var logRecord log.Record + logRecord.SetBody(log.StringValue("test log without span")) + logRecord.SetSeverity(log.SeverityInfo) + + logger.Emit(ctx, logRecord) + + // Force flush + err = provider.ForceFlush(context.Background()) + require.NoError(t, err) + + // Get exported records + records := exporter.GetRecords() + require.Len(t, records, 1) + + exportedRecord := records[0] + + // Verify no trace ID + assert.False(t, exportedRecord.TraceID().IsValid()) + + // Verify no span ID + assert.False(t, exportedRecord.SpanID().IsValid()) + }) + + t.Run("mixed DD and OTel spans in trace hierarchy", func(t *testing.T) { + // Use real tracer to get valid span IDs + tracer.Start() + defer tracer.Stop() + + // Create OTel tracer + otelProvider := opentelemetry.NewTracerProvider() + otelTracer := otelProvider.Tracer("test") + + // Create a test exporter to capture logs + exporter := newTestExporter() + + // Create LoggerProvider with test exporter + resource, err := buildResource(context.Background()) + require.NoError(t, err) + + processor := sdklog.NewSimpleProcessor(exporter) + logProvider := sdklog.NewLoggerProvider( + sdklog.WithResource(resource), + sdklog.WithProcessor(processor), + ) + defer func() { + _ = logProvider.Shutdown(context.Background()) + }() + + // Wrap with DD-aware provider + ddProvider := &ddAwareLoggerProvider{underlying: logProvider} + + // Get a logger (DD-aware - automatic bridging) + logger := ddProvider.Logger("test") + + // Start DD span + ddSpan := tracer.StartSpan("dd-parent") + defer ddSpan.Finish() + ctx := tracer.ContextWithSpan(context.Background(), ddSpan) + + // Log from DD span context (automatic bridging by DD-aware logger) + var logRecord1 log.Record + logRecord1.SetBody(log.StringValue("log from DD span")) + logRecord1.SetSeverity(log.SeverityInfo) + logger.Emit(ctx, logRecord1) + + // Start OTel child span + ctx2, otelSpan := otelTracer.Start(ctx, "otel-child") + defer otelSpan.End() + + // Log from OTel span context + var logRecord2 log.Record + logRecord2.SetBody(log.StringValue("log from OTel span")) + logRecord2.SetSeverity(log.SeverityInfo) + logger.Emit(ctx2, logRecord2) + + // Force flush + err = logProvider.ForceFlush(context.Background()) + require.NoError(t, err) + + // Get exported records + records := exporter.GetRecords() + require.Len(t, records, 2) + + // Both should have valid trace IDs + assert.True(t, records[0].TraceID().IsValid()) + assert.True(t, records[1].TraceID().IsValid()) + + // Both should have valid span IDs + assert.True(t, records[0].SpanID().IsValid()) + assert.True(t, records[1].SpanID().IsValid()) + + // First log should have DD span ID + ddSpanIDBytes := make([]byte, 8) + for i := 0; i < 8; i++ { + ddSpanIDBytes[i] = byte(ddSpan.Context().SpanID() >> (56 - 8*i)) + } + var expectedDDSpanID [8]byte + copy(expectedDDSpanID[:], ddSpanIDBytes) + actualSID1 := records[0].SpanID() + assert.Equal(t, expectedDDSpanID[:], actualSID1[:]) + + // Second log should have OTel span ID + expectedOtelSID := otelSpan.SpanContext().SpanID() + actualSID2 := records[1].SpanID() + assert.Equal(t, expectedOtelSID[:], actualSID2[:]) + }) +} diff --git a/ddtrace/opentelemetry/log/exporter_test.go b/ddtrace/opentelemetry/log/exporter_test.go new file mode 100644 index 0000000000..5a4d75bab0 --- /dev/null +++ b/ddtrace/opentelemetry/log/exporter_test.go @@ -0,0 +1,386 @@ +// 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 log + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestResolveOTLPProtocol(t *testing.T) { + t.Run("defaults to http/json", func(t *testing.T) { + protocol := resolveOTLPProtocol() + assert.Equal(t, "http/json", protocol) + }) + + t.Run("uses OTEL_EXPORTER_OTLP_PROTOCOL", func(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc") + protocol := resolveOTLPProtocol() + assert.Equal(t, "grpc", protocol) + }) + + t.Run("OTEL_EXPORTER_OTLP_LOGS_PROTOCOL wins over generic", func(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc") + t.Setenv("OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", "http/protobuf") + protocol := resolveOTLPProtocol() + assert.Equal(t, "http/protobuf", protocol) + }) + + t.Run("trims and lowercases protocol", func(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", " GRPC ") + protocol := resolveOTLPProtocol() + assert.Equal(t, "grpc", protocol) + }) + + t.Run("supports http/json", func(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", "http/json") + protocol := resolveOTLPProtocol() + assert.Equal(t, "http/json", protocol) + }) + + t.Run("supports http/protobuf", func(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", "http/protobuf") + protocol := resolveOTLPProtocol() + assert.Equal(t, "http/protobuf", protocol) + }) +} + +func TestHasOTLPEndpointInEnv(t *testing.T) { + t.Run("returns false when no env vars set", func(t *testing.T) { + assert.False(t, hasOTLPEndpointInEnv()) + }) + + t.Run("returns true when OTEL_EXPORTER_OTLP_ENDPOINT set", func(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://custom:4318") + assert.True(t, hasOTLPEndpointInEnv()) + }) + + t.Run("returns true when OTEL_EXPORTER_OTLP_LOGS_ENDPOINT set", func(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", "http://custom:4318") + assert.True(t, hasOTLPEndpointInEnv()) + }) +} + +func TestResolveOTLPEndpointHTTP(t *testing.T) { + t.Run("defaults to localhost:4318", func(t *testing.T) { + endpoint, path, insecure := resolveOTLPEndpointHTTP() + assert.Equal(t, "localhost:4318", endpoint) + assert.Equal(t, "/v1/logs", path) + assert.True(t, insecure) + }) + + t.Run("uses DD_AGENT_HOST", func(t *testing.T) { + t.Setenv("DD_AGENT_HOST", "agent.example.com") + endpoint, path, insecure := resolveOTLPEndpointHTTP() + assert.Equal(t, "agent.example.com:4318", endpoint) + assert.Equal(t, "/v1/logs", path) + assert.True(t, insecure) + }) + + t.Run("uses DD_TRACE_AGENT_URL", func(t *testing.T) { + t.Setenv("DD_TRACE_AGENT_URL", "http://trace-agent:8126") + endpoint, path, insecure := resolveOTLPEndpointHTTP() + assert.Equal(t, "trace-agent:4318", endpoint) + assert.Equal(t, "/v1/logs", path) + assert.True(t, insecure) + }) + + t.Run("DD_TRACE_AGENT_URL wins over DD_AGENT_HOST", func(t *testing.T) { + t.Setenv("DD_AGENT_HOST", "agent-host") + t.Setenv("DD_TRACE_AGENT_URL", "http://trace-agent:8126") + endpoint, _, _ := resolveOTLPEndpointHTTP() + assert.Equal(t, "trace-agent:4318", endpoint) + }) + + t.Run("preserves https scheme", func(t *testing.T) { + t.Setenv("DD_TRACE_AGENT_URL", "https://secure-agent:8126") + _, _, insecure := resolveOTLPEndpointHTTP() + assert.False(t, insecure, "https should result in insecure=false") + }) + + t.Run("handles unix socket scheme", func(t *testing.T) { + t.Setenv("DD_TRACE_AGENT_URL", "unix:///var/run/datadog/apm.socket") + _, _, insecure := resolveOTLPEndpointHTTP() + assert.True(t, insecure, "unix scheme should result in insecure=true") + }) + + t.Run("handles IPv6 addresses", func(t *testing.T) { + t.Setenv("DD_TRACE_AGENT_URL", "http://[::1]:8126") + endpoint, _, _ := resolveOTLPEndpointHTTP() + assert.Equal(t, "[::1]:4318", endpoint) + }) +} + +func TestResolveOTLPEndpointGRPC(t *testing.T) { + t.Run("defaults to localhost:4317", func(t *testing.T) { + endpoint, insecure := resolveOTLPEndpointGRPC() + assert.Equal(t, "localhost:4317", endpoint) + assert.True(t, insecure) + }) + + t.Run("uses DD_AGENT_HOST", func(t *testing.T) { + t.Setenv("DD_AGENT_HOST", "agent.example.com") + endpoint, insecure := resolveOTLPEndpointGRPC() + assert.Equal(t, "agent.example.com:4317", endpoint) + assert.True(t, insecure) + }) + + t.Run("uses DD_TRACE_AGENT_URL", func(t *testing.T) { + t.Setenv("DD_TRACE_AGENT_URL", "http://trace-agent:8126") + endpoint, insecure := resolveOTLPEndpointGRPC() + assert.Equal(t, "trace-agent:4317", endpoint) + assert.True(t, insecure) + }) + + t.Run("DD_TRACE_AGENT_URL wins over DD_AGENT_HOST", func(t *testing.T) { + t.Setenv("DD_AGENT_HOST", "agent-host") + t.Setenv("DD_TRACE_AGENT_URL", "http://trace-agent:8126") + endpoint, _ := resolveOTLPEndpointGRPC() + assert.Equal(t, "trace-agent:4317", endpoint) + }) + + t.Run("preserves https scheme", func(t *testing.T) { + t.Setenv("DD_TRACE_AGENT_URL", "https://secure-agent:8126") + _, insecure := resolveOTLPEndpointGRPC() + assert.False(t, insecure, "https should result in insecure=false") + }) +} + +func TestResolveHeaders(t *testing.T) { + t.Run("returns nil when no headers configured", func(t *testing.T) { + headers := resolveHeaders() + assert.Nil(t, headers) + }) + + t.Run("uses OTEL_EXPORTER_OTLP_HEADERS", func(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_HEADERS", "key1=value1,key2=value2") + headers := resolveHeaders() + assert.Equal(t, map[string]string{ + "key1": "value1", + "key2": "value2", + }, headers) + }) + + t.Run("OTEL_EXPORTER_OTLP_LOGS_HEADERS wins over generic", func(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_HEADERS", "generic=value") + t.Setenv("OTEL_EXPORTER_OTLP_LOGS_HEADERS", "logs=specific") + headers := resolveHeaders() + assert.Equal(t, map[string]string{ + "logs": "specific", + }, headers) + }) +} + +func TestParseHeaders(t *testing.T) { + t.Run("parses single header", func(t *testing.T) { + headers := parseHeaders("key=value") + assert.Equal(t, map[string]string{"key": "value"}, headers) + }) + + t.Run("parses multiple headers", func(t *testing.T) { + headers := parseHeaders("key1=value1,key2=value2,key3=value3") + assert.Equal(t, map[string]string{ + "key1": "value1", + "key2": "value2", + "key3": "value3", + }, headers) + }) + + t.Run("trims spaces", func(t *testing.T) { + headers := parseHeaders(" key = value , key2=value2 ") + assert.Equal(t, map[string]string{ + "key": "value", + "key2": "value2", + }, headers) + }) + + t.Run("ignores invalid entries without equals", func(t *testing.T) { + headers := parseHeaders("key1=value1,invalid,key2=value2") + assert.Equal(t, map[string]string{ + "key1": "value1", + "key2": "value2", + }, headers) + }) + + t.Run("handles empty string", func(t *testing.T) { + headers := parseHeaders("") + assert.Empty(t, headers) + }) + + t.Run("handles value with equals sign", func(t *testing.T) { + headers := parseHeaders("key=value=with=equals") + assert.Equal(t, map[string]string{ + "key": "value=with=equals", + }, headers) + }) + + t.Run("ignores entries with empty key", func(t *testing.T) { + headers := parseHeaders("=value,key=value2") + assert.Equal(t, map[string]string{ + "key": "value2", + }, headers) + }) + + t.Run("handles special characters in values", func(t *testing.T) { + headers := parseHeaders("Authorization=Bearer token123,Content-Type=application/json") + assert.Equal(t, map[string]string{ + "Authorization": "Bearer token123", + "Content-Type": "application/json", + }, headers) + }) +} + +func TestResolveExportTimeout(t *testing.T) { + t.Run("defaults to 30 seconds", func(t *testing.T) { + timeout := resolveExportTimeout() + assert.Equal(t, 30*time.Second, timeout) + }) + + t.Run("uses OTEL_EXPORTER_OTLP_TIMEOUT", func(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_TIMEOUT", "5000") + timeout := resolveExportTimeout() + assert.Equal(t, 5*time.Second, timeout) + }) + + t.Run("OTEL_EXPORTER_OTLP_LOGS_TIMEOUT wins over generic", func(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_TIMEOUT", "5000") + t.Setenv("OTEL_EXPORTER_OTLP_LOGS_TIMEOUT", "10000") + timeout := resolveExportTimeout() + assert.Equal(t, 10*time.Second, timeout) + }) + + t.Run("falls back to default on invalid value", func(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_LOGS_TIMEOUT", "invalid") + timeout := resolveExportTimeout() + assert.Equal(t, 30*time.Second, timeout) + }) +} + +func TestParseTimeout(t *testing.T) { + t.Run("parses milliseconds", func(t *testing.T) { + timeout, err := parseTimeout("1000") + assert.NoError(t, err) + assert.Equal(t, time.Second, timeout) + }) + + t.Run("handles zero", func(t *testing.T) { + timeout, err := parseTimeout("0") + assert.NoError(t, err) + assert.Equal(t, time.Duration(0), timeout) + }) + + t.Run("returns error for invalid input", func(t *testing.T) { + _, err := parseTimeout("invalid") + assert.Error(t, err) + }) + + t.Run("returns error for float", func(t *testing.T) { + _, err := parseTimeout("1000.5") + assert.Error(t, err) + }) +} + +func TestResolveBLRPMaxQueueSize(t *testing.T) { + t.Run("defaults to 2048", func(t *testing.T) { + size := resolveBLRPMaxQueueSize() + assert.Equal(t, 2048, size) + }) + + t.Run("uses OTEL_BLRP_MAX_QUEUE_SIZE", func(t *testing.T) { + t.Setenv("OTEL_BLRP_MAX_QUEUE_SIZE", "4096") + size := resolveBLRPMaxQueueSize() + assert.Equal(t, 4096, size) + }) + + t.Run("falls back to default on invalid value", func(t *testing.T) { + t.Setenv("OTEL_BLRP_MAX_QUEUE_SIZE", "invalid") + size := resolveBLRPMaxQueueSize() + assert.Equal(t, 2048, size) + }) + + t.Run("falls back to default on zero", func(t *testing.T) { + t.Setenv("OTEL_BLRP_MAX_QUEUE_SIZE", "0") + size := resolveBLRPMaxQueueSize() + assert.Equal(t, 2048, size) + }) + + t.Run("falls back to default on negative", func(t *testing.T) { + t.Setenv("OTEL_BLRP_MAX_QUEUE_SIZE", "-100") + size := resolveBLRPMaxQueueSize() + assert.Equal(t, 2048, size) + }) +} + +func TestResolveBLRPScheduleDelay(t *testing.T) { + t.Run("defaults to 1000ms", func(t *testing.T) { + delay := resolveBLRPScheduleDelay() + assert.Equal(t, 1000*time.Millisecond, delay) + }) + + t.Run("uses OTEL_BLRP_SCHEDULE_DELAY", func(t *testing.T) { + t.Setenv("OTEL_BLRP_SCHEDULE_DELAY", "500") + delay := resolveBLRPScheduleDelay() + assert.Equal(t, 500*time.Millisecond, delay) + }) + + t.Run("falls back to default on invalid value", func(t *testing.T) { + t.Setenv("OTEL_BLRP_SCHEDULE_DELAY", "invalid") + delay := resolveBLRPScheduleDelay() + assert.Equal(t, 1000*time.Millisecond, delay) + }) +} + +func TestResolveBLRPExportTimeout(t *testing.T) { + t.Run("defaults to 30000ms", func(t *testing.T) { + timeout := resolveBLRPExportTimeout() + assert.Equal(t, 30000*time.Millisecond, timeout) + }) + + t.Run("uses OTEL_BLRP_EXPORT_TIMEOUT", func(t *testing.T) { + t.Setenv("OTEL_BLRP_EXPORT_TIMEOUT", "15000") + timeout := resolveBLRPExportTimeout() + assert.Equal(t, 15000*time.Millisecond, timeout) + }) + + t.Run("falls back to default on invalid value", func(t *testing.T) { + t.Setenv("OTEL_BLRP_EXPORT_TIMEOUT", "invalid") + timeout := resolveBLRPExportTimeout() + assert.Equal(t, 30000*time.Millisecond, timeout) + }) +} + +func TestResolveBLRPMaxExportBatchSize(t *testing.T) { + t.Run("defaults to 512", func(t *testing.T) { + size := resolveBLRPMaxExportBatchSize() + assert.Equal(t, 512, size) + }) + + t.Run("uses OTEL_BLRP_MAX_EXPORT_BATCH_SIZE", func(t *testing.T) { + t.Setenv("OTEL_BLRP_MAX_EXPORT_BATCH_SIZE", "1024") + size := resolveBLRPMaxExportBatchSize() + assert.Equal(t, 1024, size) + }) + + t.Run("falls back to default on invalid value", func(t *testing.T) { + t.Setenv("OTEL_BLRP_MAX_EXPORT_BATCH_SIZE", "invalid") + size := resolveBLRPMaxExportBatchSize() + assert.Equal(t, 512, size) + }) + + t.Run("falls back to default on zero", func(t *testing.T) { + t.Setenv("OTEL_BLRP_MAX_EXPORT_BATCH_SIZE", "0") + size := resolveBLRPMaxExportBatchSize() + assert.Equal(t, 512, size) + }) + + t.Run("falls back to default on negative", func(t *testing.T) { + t.Setenv("OTEL_BLRP_MAX_EXPORT_BATCH_SIZE", "-100") + size := resolveBLRPMaxExportBatchSize() + assert.Equal(t, 512, size) + }) +} diff --git a/ddtrace/opentelemetry/log/integration_test.go b/ddtrace/opentelemetry/log/integration_test.go new file mode 100644 index 0000000000..d2b85feb82 --- /dev/null +++ b/ddtrace/opentelemetry/log/integration_test.go @@ -0,0 +1,173 @@ +// 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 log + +import ( + "context" + "testing" + + "github.com/DataDog/dd-trace-go/v2/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStartIfEnabled(t *testing.T) { + t.Run("does nothing when DD_LOGS_OTEL_ENABLED=false", func(t *testing.T) { + // Clean up any existing provider + _ = ShutdownGlobalLoggerProvider(context.Background()) + + // Ensure DD_LOGS_OTEL_ENABLED is false (default) + config.SetUseFreshConfig(true) + defer config.SetUseFreshConfig(false) + + err := StartIfEnabled(context.Background()) + assert.NoError(t, err) + + // Provider should not be initialized + provider := GetGlobalLoggerProvider() + assert.Nil(t, provider) + }) + + t.Run("initializes LoggerProvider when DD_LOGS_OTEL_ENABLED=true", func(t *testing.T) { + // Clean up any existing provider + _ = ShutdownGlobalLoggerProvider(context.Background()) + + t.Setenv("DD_LOGS_OTEL_ENABLED", "true") + config.SetUseFreshConfig(true) + defer config.SetUseFreshConfig(false) + + err := StartIfEnabled(context.Background()) + require.NoError(t, err) + + // Provider should be initialized + provider := GetGlobalLoggerProvider() + assert.NotNil(t, provider) + + // Clean up + StopIfEnabled() + }) + + t.Run("is idempotent", func(t *testing.T) { + // Clean up any existing provider + _ = ShutdownGlobalLoggerProvider(context.Background()) + + t.Setenv("DD_LOGS_OTEL_ENABLED", "true") + config.SetUseFreshConfig(true) + defer config.SetUseFreshConfig(false) + + err1 := StartIfEnabled(context.Background()) + require.NoError(t, err1) + + provider1 := GetGlobalLoggerProvider() + require.NotNil(t, provider1) + + // Call again + err2 := StartIfEnabled(context.Background()) + require.NoError(t, err2) + + provider2 := GetGlobalLoggerProvider() + require.NotNil(t, provider2) + + // Should be the same instance + assert.Same(t, provider1, provider2) + + // Clean up + StopIfEnabled() + }) +} + +func TestStopIfEnabled(t *testing.T) { + t.Run("does nothing when provider not initialized", func(t *testing.T) { + // Ensure no provider exists + _ = ShutdownGlobalLoggerProvider(context.Background()) + + // Should not panic + StopIfEnabled() + + // Provider should still be nil + provider := GetGlobalLoggerProvider() + assert.Nil(t, provider) + }) + + t.Run("shuts down initialized provider", func(t *testing.T) { + // Initialize provider + err := InitGlobalLoggerProvider(context.Background()) + require.NoError(t, err) + + provider := GetGlobalLoggerProvider() + require.NotNil(t, provider) + + // Stop + StopIfEnabled() + + // Provider should be nil after stop + provider = GetGlobalLoggerProvider() + assert.Nil(t, provider) + }) + + t.Run("is idempotent", func(t *testing.T) { + // Initialize provider + err := InitGlobalLoggerProvider(context.Background()) + require.NoError(t, err) + + // Stop multiple times + StopIfEnabled() + StopIfEnabled() + StopIfEnabled() + + // Provider should be nil + provider := GetGlobalLoggerProvider() + assert.Nil(t, provider) + }) +} + +func TestIntegration(t *testing.T) { + t.Run("full lifecycle with DD_LOGS_OTEL_ENABLED=true", func(t *testing.T) { + // Clean up any existing provider + _ = ShutdownGlobalLoggerProvider(context.Background()) + + t.Setenv("DD_LOGS_OTEL_ENABLED", "true") + t.Setenv("DD_SERVICE", "test-service") + t.Setenv("DD_ENV", "test") + t.Setenv("DD_VERSION", "1.0.0") + config.SetUseFreshConfig(true) + defer config.SetUseFreshConfig(false) + + // Start + err := StartIfEnabled(context.Background()) + require.NoError(t, err) + + provider := GetGlobalLoggerProvider() + require.NotNil(t, provider) + + // Stop + StopIfEnabled() + + provider = GetGlobalLoggerProvider() + assert.Nil(t, provider) + }) + + t.Run("full lifecycle with DD_LOGS_OTEL_ENABLED=false", func(t *testing.T) { + // Clean up any existing provider + _ = ShutdownGlobalLoggerProvider(context.Background()) + + config.SetUseFreshConfig(true) + defer config.SetUseFreshConfig(false) + + // Start (should be no-op) + err := StartIfEnabled(context.Background()) + require.NoError(t, err) + + provider := GetGlobalLoggerProvider() + assert.Nil(t, provider) + + // Stop (should be no-op) + StopIfEnabled() + + provider = GetGlobalLoggerProvider() + assert.Nil(t, provider) + }) +} diff --git a/ddtrace/opentelemetry/log/logger_provider_test.go b/ddtrace/opentelemetry/log/logger_provider_test.go new file mode 100644 index 0000000000..190f38fbec --- /dev/null +++ b/ddtrace/opentelemetry/log/logger_provider_test.go @@ -0,0 +1,201 @@ +// 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 log + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInitGlobalLoggerProvider(t *testing.T) { + t.Run("creates LoggerProvider", func(t *testing.T) { + // Clean up any existing provider + _ = ShutdownGlobalLoggerProvider(context.Background()) + + err := InitGlobalLoggerProvider(context.Background()) + require.NoError(t, err) + + provider := GetGlobalLoggerProvider() + assert.NotNil(t, provider) + + // Clean up + err = ShutdownGlobalLoggerProvider(context.Background()) + assert.NoError(t, err) + }) + + t.Run("is idempotent", func(t *testing.T) { + // Clean up any existing provider + _ = ShutdownGlobalLoggerProvider(context.Background()) + + err1 := InitGlobalLoggerProvider(context.Background()) + require.NoError(t, err1) + + provider1 := GetGlobalLoggerProvider() + require.NotNil(t, provider1) + + // Call again + err2 := InitGlobalLoggerProvider(context.Background()) + require.NoError(t, err2) + + provider2 := GetGlobalLoggerProvider() + require.NotNil(t, provider2) + + // Should be the same instance + assert.Same(t, provider1, provider2) + + // Clean up + err := ShutdownGlobalLoggerProvider(context.Background()) + assert.NoError(t, err) + }) + + t.Run("respects DD_SERVICE", func(t *testing.T) { + // Clean up any existing provider + _ = ShutdownGlobalLoggerProvider(context.Background()) + + t.Setenv("DD_SERVICE", "test-service") + + err := InitGlobalLoggerProvider(context.Background()) + require.NoError(t, err) + + provider := GetGlobalLoggerProvider() + assert.NotNil(t, provider) + + // Clean up + err = ShutdownGlobalLoggerProvider(context.Background()) + assert.NoError(t, err) + }) + + t.Run("respects BLRP environment variables", func(t *testing.T) { + // Clean up any existing provider + _ = ShutdownGlobalLoggerProvider(context.Background()) + + t.Setenv("OTEL_BLRP_MAX_QUEUE_SIZE", "1024") + t.Setenv("OTEL_BLRP_SCHEDULE_DELAY", "500") + t.Setenv("OTEL_BLRP_EXPORT_TIMEOUT", "15000") + t.Setenv("OTEL_BLRP_MAX_EXPORT_BATCH_SIZE", "256") + + err := InitGlobalLoggerProvider(context.Background()) + require.NoError(t, err) + + provider := GetGlobalLoggerProvider() + assert.NotNil(t, provider) + + // Verify env vars were read + assert.Equal(t, 1024, resolveBLRPMaxQueueSize()) + assert.Equal(t, 500*time.Millisecond, resolveBLRPScheduleDelay()) + assert.Equal(t, 15000*time.Millisecond, resolveBLRPExportTimeout()) + assert.Equal(t, 256, resolveBLRPMaxExportBatchSize()) + + // Clean up + err = ShutdownGlobalLoggerProvider(context.Background()) + assert.NoError(t, err) + }) +} + +func TestShutdownGlobalLoggerProvider(t *testing.T) { + t.Run("shuts down existing provider", func(t *testing.T) { + // Initialize provider + err := InitGlobalLoggerProvider(context.Background()) + require.NoError(t, err) + + provider := GetGlobalLoggerProvider() + require.NotNil(t, provider) + + // Shutdown + err = ShutdownGlobalLoggerProvider(context.Background()) + assert.NoError(t, err) + + // Provider should be nil after shutdown + provider = GetGlobalLoggerProvider() + assert.Nil(t, provider) + }) + + t.Run("is idempotent when no provider exists", func(t *testing.T) { + // Ensure no provider exists + _ = ShutdownGlobalLoggerProvider(context.Background()) + + // Shutdown again + err := ShutdownGlobalLoggerProvider(context.Background()) + assert.NoError(t, err) + }) + + t.Run("respects context timeout", func(t *testing.T) { + // Initialize provider + err := InitGlobalLoggerProvider(context.Background()) + require.NoError(t, err) + + // Shutdown with very short timeout + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + + // Sleep a bit to ensure context expires + time.Sleep(10 * time.Millisecond) + + err = ShutdownGlobalLoggerProvider(ctx) + // May or may not error depending on timing, but should not panic + // The important thing is it completes + + // Provider should still be cleaned up + provider := GetGlobalLoggerProvider() + assert.Nil(t, provider) + }) + + t.Run("allows reinitialization after shutdown", func(t *testing.T) { + // Initialize + err := InitGlobalLoggerProvider(context.Background()) + require.NoError(t, err) + + provider1 := GetGlobalLoggerProvider() + require.NotNil(t, provider1) + + // Shutdown + err = ShutdownGlobalLoggerProvider(context.Background()) + assert.NoError(t, err) + + // Reinitialize + err = InitGlobalLoggerProvider(context.Background()) + require.NoError(t, err) + + provider2 := GetGlobalLoggerProvider() + require.NotNil(t, provider2) + + // Should be a different instance + assert.NotSame(t, provider1, provider2) + + // Clean up + err = ShutdownGlobalLoggerProvider(context.Background()) + assert.NoError(t, err) + }) +} + +func TestGetGlobalLoggerProvider(t *testing.T) { + t.Run("returns nil when not initialized", func(t *testing.T) { + // Ensure no provider exists + _ = ShutdownGlobalLoggerProvider(context.Background()) + + provider := GetGlobalLoggerProvider() + assert.Nil(t, provider) + }) + + t.Run("returns provider when initialized", func(t *testing.T) { + // Clean up any existing provider + _ = ShutdownGlobalLoggerProvider(context.Background()) + + err := InitGlobalLoggerProvider(context.Background()) + require.NoError(t, err) + + provider := GetGlobalLoggerProvider() + assert.NotNil(t, provider) + + // Clean up + err = ShutdownGlobalLoggerProvider(context.Background()) + assert.NoError(t, err) + }) +} diff --git a/ddtrace/opentelemetry/log/resource_test.go b/ddtrace/opentelemetry/log/resource_test.go new file mode 100644 index 0000000000..1874e27374 --- /dev/null +++ b/ddtrace/opentelemetry/log/resource_test.go @@ -0,0 +1,408 @@ +// 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 log + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/sdk/resource" + semconv "go.opentelemetry.io/otel/semconv/v1.34.0" +) + +func TestBuildResource(t *testing.T) { + t.Run("empty environment returns resource with telemetry SDK", func(t *testing.T) { + res, err := buildResource(context.Background()) + require.NoError(t, err) + require.NotNil(t, res) + + attrs := res.Attributes() + // Should have telemetry.sdk.* attributes from WithTelemetrySDK() + assert.NotEmpty(t, attrs) + }) + + t.Run("DD_SERVICE maps to service.name", func(t *testing.T) { + t.Setenv("DD_SERVICE", "my-service") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + assertResourceAttribute(t, res, semconv.ServiceNameKey, "my-service") + }) + + t.Run("DD_ENV maps to deployment.environment", func(t *testing.T) { + t.Setenv("DD_ENV", "production") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + assertResourceAttribute(t, res, semconv.DeploymentEnvironmentNameKey, "production") + }) + + t.Run("DD_VERSION maps to service.version", func(t *testing.T) { + t.Setenv("DD_VERSION", "1.2.3") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + assertResourceAttribute(t, res, semconv.ServiceVersionKey, "1.2.3") + }) + + t.Run("DD_TAGS converts to resource attributes", func(t *testing.T) { + t.Setenv("DD_TAGS", "team:backend,region:us-east-1") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + assertResourceAttributeString(t, res, "team", "backend") + assertResourceAttributeString(t, res, "region", "us-east-1") + }) + + t.Run("OTEL_RESOURCE_ATTRIBUTES parses correctly", func(t *testing.T) { + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "otel.key=otel.value,another=test") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + assertResourceAttributeString(t, res, "otel.key", "otel.value") + assertResourceAttributeString(t, res, "another", "test") + }) +} + +func TestPrecedence(t *testing.T) { + t.Run("DD_SERVICE wins over OTEL service.name", func(t *testing.T) { + t.Setenv("DD_SERVICE", "dd-service") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "service.name=otel-service") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + assertResourceAttribute(t, res, semconv.ServiceNameKey, "dd-service") + }) + + t.Run("DD_ENV wins over OTEL deployment.environment.name", func(t *testing.T) { + t.Setenv("DD_ENV", "dd-env") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment.name=otel-env") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + assertResourceAttribute(t, res, semconv.DeploymentEnvironmentNameKey, "dd-env") + }) + + t.Run("DD_VERSION wins over OTEL service.version", func(t *testing.T) { + t.Setenv("DD_VERSION", "2.0.0") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "service.version=1.0.0") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + assertResourceAttribute(t, res, semconv.ServiceVersionKey, "2.0.0") + }) + + t.Run("DD_TAGS wins over OTEL custom attributes", func(t *testing.T) { + t.Setenv("DD_TAGS", "custom.key:dd-value") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "custom.key=otel-value") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + assertResourceAttributeString(t, res, "custom.key", "dd-value") + }) + + t.Run("OTEL attributes used when DD not set", func(t *testing.T) { + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "service.name=otel-service,deployment.environment.name=otel-env,service.version=1.0.0") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + assertResourceAttribute(t, res, semconv.ServiceNameKey, "otel-service") + assertResourceAttribute(t, res, semconv.DeploymentEnvironmentNameKey, "otel-env") + assertResourceAttribute(t, res, semconv.ServiceVersionKey, "1.0.0") + }) + + t.Run("mixed DD and OTEL attributes coexist", func(t *testing.T) { + t.Setenv("DD_SERVICE", "dd-service") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "otel.only=value,service.name=ignored") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + // DD wins for service.name + assertResourceAttribute(t, res, semconv.ServiceNameKey, "dd-service") + // OTEL attribute that doesn't conflict is preserved + assertResourceAttributeString(t, res, "otel.only", "value") + }) +} + +func TestHostname(t *testing.T) { + t.Run("no hostname by default", func(t *testing.T) { + res, err := buildResource(context.Background()) + require.NoError(t, err) + + // Should not have host.name attribute + attrs := res.Attributes() + for _, attr := range attrs { + assert.NotEqual(t, semconv.HostNameKey, attr.Key, "host.name should not be present by default") + } + }) + + t.Run("DD_HOSTNAME alone does not set hostname", func(t *testing.T) { + t.Setenv("DD_HOSTNAME", "my-host") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + // DD_HOSTNAME alone should NOT add hostname - needs DD_TRACE_REPORT_HOSTNAME=true + attrs := res.Attributes() + for _, attr := range attrs { + assert.NotEqual(t, semconv.HostNameKey, attr.Key, "host.name should not be present without DD_TRACE_REPORT_HOSTNAME=true") + } + }) + + t.Run("DD_TRACE_REPORT_HOSTNAME=true uses detected hostname", func(t *testing.T) { + t.Setenv("DD_TRACE_REPORT_HOSTNAME", "true") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + // Should have hostname from os.Hostname() + detectedHostname, _ := os.Hostname() + if detectedHostname != "" { + assertResourceAttribute(t, res, semconv.HostNameKey, detectedHostname) + } + }) + + t.Run("DD_TRACE_REPORT_HOSTNAME=false does not add hostname", func(t *testing.T) { + t.Setenv("DD_TRACE_REPORT_HOSTNAME", "false") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + attrs := res.Attributes() + for _, attr := range attrs { + assert.NotEqual(t, semconv.HostNameKey, attr.Key, "host.name should not be present when DD_TRACE_REPORT_HOSTNAME=false") + } + }) + + t.Run("DD_HOSTNAME wins over DD_TRACE_REPORT_HOSTNAME", func(t *testing.T) { + t.Setenv("DD_HOSTNAME", "explicit-host") + t.Setenv("DD_TRACE_REPORT_HOSTNAME", "true") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + assertResourceAttribute(t, res, semconv.HostNameKey, "explicit-host") + }) + + t.Run("OTEL host.name has highest priority", func(t *testing.T) { + t.Setenv("DD_HOSTNAME", "dd-host") + t.Setenv("DD_TRACE_REPORT_HOSTNAME", "true") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "host.name=otel-host") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + // OTEL_RESOURCE_ATTRIBUTES[host.name] always wins, even over DD_HOSTNAME + DD_TRACE_REPORT_HOSTNAME + assertResourceAttribute(t, res, semconv.HostNameKey, "otel-host") + }) + + t.Run("OTEL host.name used when DD not set", func(t *testing.T) { + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "host.name=otel-host") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + assertResourceAttribute(t, res, semconv.HostNameKey, "otel-host") + }) + + t.Run("DD_HOSTNAME without DD_TRACE_REPORT_HOSTNAME does not add hostname", func(t *testing.T) { + t.Setenv("DD_HOSTNAME", "should-not-appear") + // DD_TRACE_REPORT_HOSTNAME not set - hostname should not be added + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + // Should not have host.name attribute + attrs := res.Attributes() + for _, attr := range attrs { + assert.NotEqual(t, semconv.HostNameKey, attr.Key, "host.name should not be present without DD_TRACE_REPORT_HOSTNAME=true") + } + }) +} + +func TestInvalidInputs(t *testing.T) { + t.Run("empty DD_TAGS is handled gracefully", func(t *testing.T) { + t.Setenv("DD_TAGS", "") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + require.NotNil(t, res) + }) + + t.Run("malformed DD_TAGS handled gracefully", func(t *testing.T) { + t.Setenv("DD_TAGS", "invalid-no-value,valid:value") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + // Valid tag should be present + assertResourceAttributeString(t, res, "valid", "value") + }) + + t.Run("empty OTEL_RESOURCE_ATTRIBUTES is handled gracefully", func(t *testing.T) { + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + require.NotNil(t, res) + }) + + t.Run("malformed OTEL_RESOURCE_ATTRIBUTES handled gracefully", func(t *testing.T) { + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "invalid-no-equals,valid=value") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + // Valid attribute should be present + assertResourceAttributeString(t, res, "valid", "value") + }) + + t.Run("special characters in values preserved", func(t *testing.T) { + t.Setenv("DD_TAGS", "special:with-dash_underscore.dot") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + assertResourceAttributeString(t, res, "special", "with-dash_underscore.dot") + }) +} + +func TestComplexScenarios(t *testing.T) { + t.Run("all DD settings together", func(t *testing.T) { + t.Setenv("DD_SERVICE", "my-service") + t.Setenv("DD_ENV", "staging") + t.Setenv("DD_VERSION", "3.0.0") + t.Setenv("DD_TAGS", "team:platform,tier:critical") + t.Setenv("DD_HOSTNAME", "server-01") + t.Setenv("DD_TRACE_REPORT_HOSTNAME", "true") // Required to enable hostname reporting + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + assertResourceAttribute(t, res, semconv.ServiceNameKey, "my-service") + assertResourceAttribute(t, res, semconv.DeploymentEnvironmentNameKey, "staging") + assertResourceAttribute(t, res, semconv.ServiceVersionKey, "3.0.0") + assertResourceAttributeString(t, res, "team", "platform") + assertResourceAttributeString(t, res, "tier", "critical") + assertResourceAttribute(t, res, semconv.HostNameKey, "server-01") + }) + + t.Run("DD overrides OTEL for service/env/version except hostname", func(t *testing.T) { + t.Setenv("DD_SERVICE", "dd-service") + t.Setenv("DD_ENV", "dd-env") + t.Setenv("DD_VERSION", "dd-version") + t.Setenv("DD_TAGS", "custom:dd-tag") + t.Setenv("DD_HOSTNAME", "dd-host") + t.Setenv("DD_TRACE_REPORT_HOSTNAME", "true") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "service.name=otel-service,deployment.environment.name=otel-env,service.version=otel-version,custom=otel-tag,host.name=otel-host") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + // DD values win for service/env/version/custom, but OTEL host.name has highest priority + assertResourceAttribute(t, res, semconv.ServiceNameKey, "dd-service") + assertResourceAttribute(t, res, semconv.DeploymentEnvironmentNameKey, "dd-env") + assertResourceAttribute(t, res, semconv.ServiceVersionKey, "dd-version") + assertResourceAttributeString(t, res, "custom", "dd-tag") + // OTEL host.name wins (has highest priority per OTel spec) + assertResourceAttribute(t, res, semconv.HostNameKey, "otel-host") + }) + + t.Run("multiple DD_TAGS with same key uses last value", func(t *testing.T) { + // Note: This tests internal.ParseTagString behavior + t.Setenv("DD_TAGS", "key:first,key:second") + + res, err := buildResource(context.Background()) + require.NoError(t, err) + + // The last value should win (depends on internal.ParseTagString implementation) + attrs := res.Attributes() + hasKey := false + for _, attr := range attrs { + if attr.Key == "key" { + hasKey = true + // Value will be one of them depending on map iteration + assert.Contains(t, []string{"first", "second"}, attr.Value.AsString()) + } + } + assert.True(t, hasKey, "key should be present in attributes") + }) +} + +func TestParseOtelResourceAttributes(t *testing.T) { + t.Run("empty string returns empty map", func(t *testing.T) { + result := parseOtelResourceAttributes("") + assert.Empty(t, result) + }) + + t.Run("single attribute", func(t *testing.T) { + result := parseOtelResourceAttributes("key=value") + assert.Equal(t, map[string]string{"key": "value"}, result) + }) + + t.Run("multiple attributes", func(t *testing.T) { + result := parseOtelResourceAttributes("key1=value1,key2=value2,key3=value3") + assert.Equal(t, map[string]string{ + "key1": "value1", + "key2": "value2", + "key3": "value3", + }, result) + }) + + t.Run("attributes with dots and underscores", func(t *testing.T) { + result := parseOtelResourceAttributes("service.name=my-service,host_name=my-host") + assert.Equal(t, map[string]string{ + "service.name": "my-service", + "host_name": "my-host", + }, result) + }) + + t.Run("values with special characters", func(t *testing.T) { + result := parseOtelResourceAttributes("key=value-with-dash_and_underscore.and.dot") + assert.Equal(t, map[string]string{"key": "value-with-dash_and_underscore.and.dot"}, result) + }) +} + +// Helper functions + +func assertResourceAttribute(t *testing.T, res *resource.Resource, key attribute.Key, expectedValue string) { + t.Helper() + attrs := res.Attributes() + for _, attr := range attrs { + if attr.Key == key { + assert.Equal(t, expectedValue, attr.Value.AsString(), "unexpected value for %s", key) + return + } + } + t.Errorf("attribute %s not found in resource", key) +} + +func assertResourceAttributeString(t *testing.T, res *resource.Resource, key string, expectedValue string) { + t.Helper() + attrs := res.Attributes() + for _, attr := range attrs { + if string(attr.Key) == key { + assert.Equal(t, expectedValue, attr.Value.AsString(), "unexpected value for %s", key) + return + } + } + t.Errorf("attribute %s not found in resource", key) +} diff --git a/ddtrace/opentelemetry/log/telemetry_test.go b/ddtrace/opentelemetry/log/telemetry_test.go new file mode 100644 index 0000000000..9ed9b216bb --- /dev/null +++ b/ddtrace/opentelemetry/log/telemetry_test.go @@ -0,0 +1,254 @@ +// 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 log + +import ( + "context" + "testing" + + "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" + sdklog "go.opentelemetry.io/otel/sdk/log" +) + +// TestRegisterTelemetry verifies that registerTelemetry reports all expected configurations. +func TestRegisterTelemetry(t *testing.T) { + t.Run("reports all OTLP configurations", func(t *testing.T) { + recorder := &telemetrytest.RecordClient{} + defer telemetry.MockClient(recorder)() + + t.Setenv(envOTLPTimeout, "5000") + t.Setenv(envOTLPHeaders, "api-key=secret") + t.Setenv(envOTLPProtocol, "http/protobuf") + t.Setenv(envOTLPEndpoint, "http://example.com:4318") + t.Setenv(envOTLPLogsTimeout, "8000") + t.Setenv(envOTLPLogsHeaders, "log-key=value") + t.Setenv(envOTLPLogsProtocol, "grpc") + t.Setenv(envOTLPLogsEndpoint, "http://logs.example.com:4317") + + registerTelemetry() + + // Verify generic OTLP configurations + telemetrytest.CheckConfig(t, recorder.Configuration, envOTLPTimeout, 5000) + telemetrytest.CheckConfig(t, recorder.Configuration, envOTLPHeaders, "api-key=secret") + telemetrytest.CheckConfig(t, recorder.Configuration, envOTLPProtocol, "http/protobuf") + telemetrytest.CheckConfig(t, recorder.Configuration, envOTLPEndpoint, "http://example.com:4318") + + // Verify logs-specific configurations + telemetrytest.CheckConfig(t, recorder.Configuration, envOTLPLogsTimeout, 8000) + telemetrytest.CheckConfig(t, recorder.Configuration, envOTLPLogsHeaders, "log-key=value") + telemetrytest.CheckConfig(t, recorder.Configuration, envOTLPLogsProtocol, "grpc") + telemetrytest.CheckConfig(t, recorder.Configuration, envOTLPLogsEndpoint, "http://logs.example.com:4317") + }) + + t.Run("reports BLRP configurations", func(t *testing.T) { + recorder := &telemetrytest.RecordClient{} + defer telemetry.MockClient(recorder)() + + t.Setenv(envBLRPMaxQueueSize, "4096") + t.Setenv(envBLRPScheduleDelay, "2000") + t.Setenv(envBLRPExportTimeout, "60000") + t.Setenv(envBLRPMaxExportBatchSize, "1024") + + registerTelemetry() + + telemetrytest.CheckConfig(t, recorder.Configuration, envBLRPMaxQueueSize, 4096) + telemetrytest.CheckConfig(t, recorder.Configuration, envBLRPScheduleDelay, 2000) + telemetrytest.CheckConfig(t, recorder.Configuration, envBLRPExportTimeout, 60000) + telemetrytest.CheckConfig(t, recorder.Configuration, envBLRPMaxExportBatchSize, 1024) + }) + + t.Run("reports default values when env vars not set", func(t *testing.T) { + recorder := &telemetrytest.RecordClient{} + defer telemetry.MockClient(recorder)() + + registerTelemetry() + + // Check that defaults are reported with OriginDefault + var foundLogsTimeout, foundMaxQueueSize, foundScheduleDelay, foundExportTimeout, foundMaxBatchSize bool + + for _, cfg := range recorder.Configuration { + switch cfg.Name { + case envOTLPLogsTimeout: + foundLogsTimeout = true + assert.Equal(t, defaultOTLPTimeoutMs, cfg.Value) + assert.Equal(t, telemetry.OriginDefault, cfg.Origin) + case envBLRPMaxQueueSize: + foundMaxQueueSize = true + assert.Equal(t, defaultBLRPMaxQueueSize, cfg.Value) + assert.Equal(t, telemetry.OriginDefault, cfg.Origin) + case envBLRPScheduleDelay: + foundScheduleDelay = true + assert.Equal(t, defaultBLRPScheduleDelayMs, cfg.Value) + assert.Equal(t, telemetry.OriginDefault, cfg.Origin) + case envBLRPExportTimeout: + foundExportTimeout = true + assert.Equal(t, defaultBLRPExportTimeoutMs, cfg.Value) + assert.Equal(t, telemetry.OriginDefault, cfg.Origin) + case envBLRPMaxExportBatchSize: + foundMaxBatchSize = true + assert.Equal(t, defaultBLRPMaxExportBatchSize, cfg.Value) + assert.Equal(t, telemetry.OriginDefault, cfg.Origin) + } + } + + assert.True(t, foundLogsTimeout, "expected OTEL_EXPORTER_OTLP_LOGS_TIMEOUT config") + assert.True(t, foundMaxQueueSize, "expected OTEL_BLRP_MAX_QUEUE_SIZE config") + assert.True(t, foundScheduleDelay, "expected OTEL_BLRP_SCHEDULE_DELAY config") + assert.True(t, foundExportTimeout, "expected OTEL_BLRP_EXPORT_TIMEOUT config") + assert.True(t, foundMaxBatchSize, "expected OTEL_BLRP_MAX_EXPORT_BATCH_SIZE config") + }) +} + +// TestLogsExportTelemetry verifies that the LogsExportTelemetry struct correctly +// tracks log record exports with different protocols and encodings. +func TestLogsExportTelemetry(t *testing.T) { + t.Run("http/json", func(t *testing.T) { + recorder := &telemetrytest.RecordClient{} + defer telemetry.MockClient(recorder)() + + // Create telemetry tracker for HTTP/JSON + let := NewLogsExportTelemetry("http", "json") + + // Record some log exports + let.RecordLogRecords(5) + let.RecordLogRecords(10) + let.RecordLogRecords(3) + + // Check that metrics were recorded + key := telemetrytest.MetricKey{ + Namespace: telemetry.NamespaceGeneral, + Name: "otel.log_records", + Tags: "encoding:json,protocol:http", + Kind: "count", + } + + assert.Contains(t, recorder.Metrics, key, "expected otel.log_records metric") + if handle, ok := recorder.Metrics[key]; ok { + assert.Equal(t, float64(18), handle.Get(), "expected total count of 18 log records (5+10+3)") + } + }) + + t.Run("http/protobuf", func(t *testing.T) { + recorder := &telemetrytest.RecordClient{} + defer telemetry.MockClient(recorder)() + + // Create telemetry tracker for HTTP/protobuf + let := NewLogsExportTelemetry("http", "protobuf") + + let.RecordLogRecords(7) + + // Check that metrics were recorded with correct tags + key := telemetrytest.MetricKey{ + Namespace: telemetry.NamespaceGeneral, + Name: "otel.log_records", + Tags: "encoding:protobuf,protocol:http", + Kind: "count", + } + + assert.Contains(t, recorder.Metrics, key, "expected otel.log_records metric with protobuf tag") + if handle, ok := recorder.Metrics[key]; ok { + assert.Equal(t, float64(7), handle.Get(), "expected 7 log records") + } + }) + + t.Run("grpc/protobuf", func(t *testing.T) { + recorder := &telemetrytest.RecordClient{} + defer telemetry.MockClient(recorder)() + + // Create telemetry tracker for gRPC/protobuf + let := NewLogsExportTelemetry("grpc", "protobuf") + + let.RecordLogRecords(12) + + // Check that metrics were recorded with correct tags + key := telemetrytest.MetricKey{ + Namespace: telemetry.NamespaceGeneral, + Name: "otel.log_records", + Tags: "encoding:protobuf,protocol:grpc", + Kind: "count", + } + + assert.Contains(t, recorder.Metrics, key, "expected otel.log_records metric with grpc tag") + if handle, ok := recorder.Metrics[key]; ok { + assert.Equal(t, float64(12), handle.Get(), "expected 12 log records") + } + }) + + t.Run("exporter integration", func(t *testing.T) { + recorder := &telemetrytest.RecordClient{} + defer telemetry.MockClient(recorder)() + + // Create a test exporter + testExp := &testExporter{} + let := NewLogsExportTelemetry("http", "json") + + // Wrap it with telemetry + te := &telemetryExporter{ + Exporter: testExp, + telemetry: let, + } + + ctx := context.Background() + + // Create some test log records + records := []sdklog.Record{ + {}, // Empty records for testing + {}, + {}, + } + + // Export records + err := te.Export(ctx, records) + require.NoError(t, err) + + // Verify telemetry was recorded + key := telemetrytest.MetricKey{ + Namespace: telemetry.NamespaceGeneral, + Name: "otel.log_records", + Tags: "encoding:json,protocol:http", + Kind: "count", + } + + assert.Contains(t, recorder.Metrics, key, "expected otel.log_records metric") + if handle, ok := recorder.Metrics[key]; ok { + assert.Equal(t, float64(3), handle.Get(), "expected 3 log records to be counted") + } + }) + + t.Run("nil telemetry doesn't panic", func(t *testing.T) { + var let *LogsExportTelemetry // nil + + // Should not panic + let.RecordLogRecords(5) + }) + + t.Run("zero count not recorded", func(t *testing.T) { + recorder := &telemetrytest.RecordClient{} + defer telemetry.MockClient(recorder)() + + let := NewLogsExportTelemetry("http", "json") + + // Record zero + let.RecordLogRecords(0) + + // Verify no metrics were recorded + key := telemetrytest.MetricKey{ + Namespace: telemetry.NamespaceGeneral, + Name: "otel.log_records", + Tags: "encoding:json,protocol:http", + Kind: "count", + } + + // The key might exist but should have zero value, or not exist at all + if handle, ok := recorder.Metrics[key]; ok { + assert.Equal(t, float64(0), handle.Get(), "expected zero count for zero records") + } + }) +} diff --git a/ddtrace/opentelemetry/log/test_exporter.go b/ddtrace/opentelemetry/log/test_exporter.go new file mode 100644 index 0000000000..d86f44a04b --- /dev/null +++ b/ddtrace/opentelemetry/log/test_exporter.go @@ -0,0 +1,76 @@ +// 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 log + +import ( + "context" + "sync" + + sdklog "go.opentelemetry.io/otel/sdk/log" +) + +// testExporter is a simple in-memory exporter for testing. +// It captures all exported log records for verification. +type testExporter struct { + mu sync.Mutex + records []sdklog.Record + stopped bool +} + +// newTestExporter creates a new test exporter. +func newTestExporter() *testExporter { + return &testExporter{ + records: make([]sdklog.Record, 0), + } +} + +// Export captures the log records. +func (e *testExporter) Export(ctx context.Context, records []sdklog.Record) error { + e.mu.Lock() + defer e.mu.Unlock() + + if e.stopped { + return nil + } + + // Make a copy of each record to avoid mutation + for _, r := range records { + e.records = append(e.records, r) + } + + return nil +} + +// Shutdown shuts down the exporter. +func (e *testExporter) Shutdown(ctx context.Context) error { + e.mu.Lock() + defer e.mu.Unlock() + e.stopped = true + return nil +} + +// ForceFlush is a no-op for the test exporter. +func (e *testExporter) ForceFlush(ctx context.Context) error { + return nil +} + +// GetRecords returns all captured log records. +func (e *testExporter) GetRecords() []sdklog.Record { + e.mu.Lock() + defer e.mu.Unlock() + + // Return a copy to avoid external mutation + result := make([]sdklog.Record, len(e.records)) + copy(result, e.records) + return result +} + +// Reset clears all captured records. +func (e *testExporter) Reset() { + e.mu.Lock() + defer e.mu.Unlock() + e.records = make([]sdklog.Record, 0) +}