diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs index 218b4721caa..6137c11a701 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs @@ -27,6 +27,8 @@ public static THeaders GetHeaders(this OtlpExporterOptions options, Ac { var optionHeaders = options.Headers; var headers = new THeaders(); + string? customUserAgent = null; + if (!string.IsNullOrEmpty(optionHeaders)) { // According to the specification, URL-encoded headers must be supported. @@ -56,13 +58,33 @@ public static THeaders GetHeaders(this OtlpExporterOptions options, Ac var key = pair.Slice(0, equalIndex).Trim().ToString(); var value = pair.Slice(equalIndex + 1).Trim().ToString(); - addHeader(headers, key, value); + + // Extract custom User-Agent to prepend to default + if (string.Equals(key, "User-Agent", StringComparison.OrdinalIgnoreCase)) + { + customUserAgent = value; + } + else + { + addHeader(headers, key, value); + } } } foreach (var header in OtlpExporterOptions.StandardHeaders) { - addHeader(headers, header.Key, header.Value); + if (string.Equals(header.Key, "User-Agent", StringComparison.OrdinalIgnoreCase)) + { + // Create User-Agent with custom prefix if provided + var userAgentValue = string.IsNullOrWhiteSpace(customUserAgent) + ? header.Value + : $"{customUserAgent!.Trim()} {header.Value}"; + addHeader(headers, header.Key, userAgentValue); + } + else + { + addHeader(headers, header.Key, header.Value); + } } return headers; diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs index 8e0c24601a4..80053fd2462 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs @@ -4,6 +4,7 @@ #if NETFRAMEWORK using System.Net.Http; #endif +using System.Linq; using Microsoft.Extensions.Configuration; using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation; using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient; @@ -133,6 +134,92 @@ public void GetTransmissionHandler_InitializesCorrectHandlerExportClientAndTimeo AssertTransmissionHandler(transmissionHandler, exportClientType, expectedTimeoutMilliseconds, retryStrategy); } + [Fact] + public void GetHeaders_NoUserAgentInHeaders_ReturnsDefaultUserAgent() + { + var options = new OtlpExporterOptions + { + Headers = "Authorization=Bearer token123", + }; + + var headers = options.GetHeaders>((d, k, v) => d.Add(k, v)); + var userAgentHeader = headers.FirstOrDefault(h => h.Key == "User-Agent"); + + Assert.NotEqual(default(KeyValuePair), userAgentHeader); + Assert.StartsWith("OTel-OTLP-Exporter-Dotnet/", userAgentHeader.Value, StringComparison.Ordinal); + } + + [Fact] + public void GetHeaders_UserAgentInHeaders_PrependsToDefault() + { + var options = new OtlpExporterOptions + { + Headers = "User-Agent=MyDistribution/1.0.0", + }; + + var headers = options.GetHeaders>((d, k, v) => d.Add(k, v)); + var userAgentHeader = headers.FirstOrDefault(h => h.Key == "User-Agent"); + + Assert.NotEqual(default(KeyValuePair), userAgentHeader); + Assert.StartsWith("MyDistribution/1.0.0 OTel-OTLP-Exporter-Dotnet/", userAgentHeader.Value, StringComparison.Ordinal); + } + + [Fact] + public void GetHeaders_UserAgentWithWhitespace_TrimmedAndPrepended() + { + var options = new OtlpExporterOptions + { + Headers = "User-Agent= MyService/2.1.0 ", + }; + + var headers = options.GetHeaders>((d, k, v) => d.Add(k, v)); + var userAgentHeader = headers.FirstOrDefault(h => h.Key == "User-Agent"); + + Assert.NotEqual(default(KeyValuePair), userAgentHeader); + Assert.StartsWith("MyService/2.1.0 OTel-OTLP-Exporter-Dotnet/", userAgentHeader.Value, StringComparison.Ordinal); + Assert.DoesNotContain(" ", userAgentHeader.Value, StringComparison.Ordinal); + } + + [Fact] + public void GetHeaders_UserAgentWithOtherHeaders_PrependsCorrectly() + { + var options = new OtlpExporterOptions + { + Headers = "Authorization=Bearer token,User-Agent=CustomAgent/3.0.0,Content-Type=application/json", + }; + + var headers = options.GetHeaders>((d, k, v) => d.Add(k, v)); + + // Should have Authorization, Content-Type, and User-Agent (from standard headers with custom prepended) + Assert.Equal(3, headers.Count); + Assert.Contains(headers, h => h.Key == "Authorization" && h.Value == "Bearer token"); + Assert.Contains(headers, h => h.Key == "Content-Type" && h.Value == "application/json"); + + var userAgentHeader = headers.FirstOrDefault(h => h.Key == "User-Agent"); + Assert.NotEqual(default(KeyValuePair), userAgentHeader); + Assert.StartsWith("CustomAgent/3.0.0 OTel-OTLP-Exporter-Dotnet/", userAgentHeader.Value, StringComparison.Ordinal); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void GetHeaders_EmptyOrWhitespaceUserAgent_UsesDefault(string userAgentValue) + { + var options = new OtlpExporterOptions + { + Headers = $"User-Agent={userAgentValue}", + }; + + var headers = options.GetHeaders>((d, k, v) => d.Add(k, v)); + var userAgentHeader = headers.FirstOrDefault(h => h.Key == "User-Agent"); + + Assert.NotEqual(default(KeyValuePair), userAgentHeader); + Assert.StartsWith("OTel-OTLP-Exporter-Dotnet/", userAgentHeader.Value, StringComparison.Ordinal); + + // Should not have extra spaces or the empty custom prefix + Assert.DoesNotContain(" ", userAgentHeader.Value, StringComparison.Ordinal); + } + private static void AssertTransmissionHandler(OtlpExporterTransmissionHandler transmissionHandler, Type exportClientType, int expectedTimeoutMilliseconds, string? retryStrategy) { if (retryStrategy == "in_memory")