diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md index ad7456e2386..ed35ad22226 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md @@ -7,6 +7,12 @@ Notes](../../RELEASENOTES.md). ## Unreleased +* Added `UserAgentProductIdentifier` property to `OtlpExporterOptions` to allow + custom product identifiers to be prepended to the User-Agent header. When set, + the custom identifier is prepended with a space separator to the default + User-Agent string (e.g., "MyApp/1.0 OTel-OTLP-Exporter-Dotnet/1.14.0"). + ([#6496]) + ## 1.14.0 Released 2025-Nov-12 diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs index 8004aa689e0..8badc7a5ad1 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs @@ -1,4 +1,4 @@ -// Copyright The OpenTelemetry Authors +// Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 using System.Diagnostics; @@ -32,10 +32,16 @@ public class OtlpExporterOptions : IOtlpExporterOptions internal const OtlpExportProtocol DefaultOtlpExportProtocol = OtlpExportProtocol.Grpc; #endif - internal static readonly KeyValuePair[] StandardHeaders = new KeyValuePair[] - { - new("User-Agent", GetUserAgentString()), - }; + private static readonly string baseUserAgent = $"OTel-OTLP-Exporter-Dotnet/{typeof(OtlpExporterOptions).Assembly.GetPackageVersion()}"; + private static readonly KeyValuePair[] DefaultHeaders = + [ + new("User-Agent", baseUserAgent) + ]; + + internal KeyValuePair[] StandardHeaders => + string.IsNullOrEmpty(this.UserAgentProductIdentifier) + ? DefaultHeaders + : [new("User-Agent", $"{this.UserAgentProductIdentifier} {baseUserAgent}")]; internal readonly Func DefaultHttpClientFactory; @@ -124,6 +130,12 @@ public OtlpExportProtocol Protocol set => this.protocol = value; } + /// + /// Gets or sets a custom user agent identifier. + /// This will be prepended to the default user agent string. + /// + public string UserAgentProductIdentifier { get; set; } = string.Empty; + /// /// Gets or sets the export processor type to be used with the OpenTelemetry Protocol Exporter. The default value is . /// @@ -226,12 +238,6 @@ internal OtlpExporterOptions ApplyDefaults(OtlpExporterOptions defaultExporterOp return this; } - private static string GetUserAgentString() - { - var assembly = typeof(OtlpExporterOptions).Assembly; - return $"OTel-OTLP-Exporter-Dotnet/{assembly.GetPackageVersion()}"; - } - private void ApplyConfiguration( IConfiguration configuration, OtlpExporterOptionsConfigurationType configurationType) diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs index 218b4721caa..c00c7e14efb 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs @@ -60,7 +60,7 @@ public static THeaders GetHeaders(this OtlpExporterOptions options, Ac } } - foreach (var header in OtlpExporterOptions.StandardHeaders) + foreach (var header in options.StandardHeaders) { addHeader(headers, header.Key, header.Value); } diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/Implementation/ExportClient/OtlpHttpTraceExportClientTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/Implementation/ExportClient/OtlpHttpTraceExportClientTests.cs index 92262f35429..bc3cdcca101 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/Implementation/ExportClient/OtlpHttpTraceExportClientTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/Implementation/ExportClient/OtlpHttpTraceExportClientTests.cs @@ -57,13 +57,13 @@ public void NewOtlpHttpTraceExportClient_OtlpExporterOptions_ExporterHasCorrectP Assert.NotNull(client.HttpClient); - Assert.Equal(2 + OtlpExporterOptions.StandardHeaders.Length, client.Headers.Count); + Assert.Equal(2 + options.StandardHeaders.Length, client.Headers.Count); Assert.Contains(client.Headers, kvp => kvp.Key == header1.Name && kvp.Value == header1.Value); Assert.Contains(client.Headers, kvp => kvp.Key == header2.Name && kvp.Value == header2.Value); - for (int i = 0; i < OtlpExporterOptions.StandardHeaders.Length; i++) + for (int i = 0; i < options.StandardHeaders.Length; i++) { - Assert.Contains(client.Headers, entry => entry.Key == OtlpExporterOptions.StandardHeaders[i].Key && entry.Value == OtlpExporterOptions.StandardHeaders[i].Value); + Assert.Contains(client.Headers, entry => entry.Key == options.StandardHeaders[i].Key && entry.Value == options.StandardHeaders[i].Value); } } @@ -156,13 +156,13 @@ void RunTest(Batch batch) Assert.Equal(HttpMethod.Post, httpRequest.Method); Assert.NotNull(httpRequest.RequestUri); Assert.Equal("http://localhost:4317/", httpRequest.RequestUri.AbsoluteUri); - Assert.Equal(OtlpExporterOptions.StandardHeaders.Length + 2, httpRequest.Headers.Count()); + Assert.Equal(options.StandardHeaders.Length + 2, httpRequest.Headers.Count()); Assert.Contains(httpRequest.Headers, h => h.Key == header1.Name && h.Value.First() == header1.Value); Assert.Contains(httpRequest.Headers, h => h.Key == header2.Name && h.Value.First() == header2.Value); - for (int i = 0; i < OtlpExporterOptions.StandardHeaders.Length; i++) + for (int i = 0; i < options.StandardHeaders.Length; i++) { - Assert.Contains(httpRequest.Headers, entry => entry.Key == OtlpExporterOptions.StandardHeaders[i].Key && entry.Value.First() == OtlpExporterOptions.StandardHeaders[i].Value); + Assert.Contains(httpRequest.Headers, entry => entry.Key == options.StandardHeaders[i].Key && entry.Value.First() == options.StandardHeaders[i].Value); } Assert.NotNull(testHttpHandler.HttpRequestContent); diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs index 11f2d9563e9..0c3457e36d5 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs @@ -25,11 +25,11 @@ public void GetHeaders_NoOptionHeaders_ReturnsStandardHeaders(string? optionHead var headers = options.GetHeaders>((d, k, v) => d.Add(k, v)); - Assert.Equal(OtlpExporterOptions.StandardHeaders.Length, headers.Count); + Assert.Equal(options.StandardHeaders.Length, headers.Count); - for (int i = 0; i < OtlpExporterOptions.StandardHeaders.Length; i++) + for (int i = 0; i < options.StandardHeaders.Length; i++) { - Assert.Contains(headers, entry => entry.Key == OtlpExporterOptions.StandardHeaders[i].Key && entry.Value == OtlpExporterOptions.StandardHeaders[i].Value); + Assert.Contains(headers, entry => entry.Key == options.StandardHeaders[i].Key && entry.Value == options.StandardHeaders[i].Value); } } @@ -185,14 +185,14 @@ private static void VerifyHeaders(string inputOptionHeaders, string expectedNorm } } - Assert.Equal(OtlpExporterOptions.StandardHeaders.Length + expectedOptional.Count, headers.Count); + Assert.Equal(options.StandardHeaders.Length + expectedOptional.Count, headers.Count); foreach (var kvp in expectedOptional) { Assert.Contains(headers, h => h.Key == kvp.Key && h.Value == kvp.Value); } - foreach (var std in OtlpExporterOptions.StandardHeaders) + foreach (var std in options.StandardHeaders) { Assert.Contains(headers, h => h.Key == std.Key && h.Value == std.Value); } diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs index d6ebe39e78b..a7d9de14395 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs @@ -262,4 +262,102 @@ public void OtlpExporterOptions_ApplyDefaultsTest() Assert.NotEqual(defaultOptionsWithData.TimeoutMilliseconds, targetOptionsWithData.TimeoutMilliseconds); Assert.NotEqual(defaultOptionsWithData.HttpClientFactory, targetOptionsWithData.HttpClientFactory); } + + [Fact] + public void UserAgentProductIdentifier_Default_IsEmpty() + { + var options = new OtlpExporterOptions(); + + Assert.Equal(string.Empty, options.UserAgentProductIdentifier); + } + + [Fact] + public void UserAgentProductIdentifier_DefaultUserAgent_ContainsExporterInfo() + { + var options = new OtlpExporterOptions(); + + var userAgentHeader = options.StandardHeaders.FirstOrDefault(h => h.Key == "User-Agent"); + + Assert.NotNull(userAgentHeader.Key); + Assert.StartsWith("OTel-OTLP-Exporter-Dotnet/", userAgentHeader.Value, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void UserAgentProductIdentifier_WithProductIdentifier_IsPrepended() + { + var options = new OtlpExporterOptions + { + UserAgentProductIdentifier = "MyDistribution/1.2.3", + }; + + Assert.Equal("MyDistribution/1.2.3", options.UserAgentProductIdentifier); + + var userAgentHeader = options.StandardHeaders.FirstOrDefault(h => h.Key == "User-Agent"); + + Assert.NotNull(userAgentHeader.Key); + Assert.StartsWith("MyDistribution/1.2.3 OTel-OTLP-Exporter-Dotnet/", userAgentHeader.Value, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void UserAgentProductIdentifier_UpdatesStandardHeaders() + { + var options = new OtlpExporterOptions(); + + var initialUserAgent = options.StandardHeaders.FirstOrDefault(h => h.Key == "User-Agent").Value; + Assert.StartsWith("OTel-OTLP-Exporter-Dotnet/", initialUserAgent, StringComparison.OrdinalIgnoreCase); + + options.UserAgentProductIdentifier = "MyProduct/1.0.0"; + + var updatedUserAgent = options.StandardHeaders.FirstOrDefault(h => h.Key == "User-Agent").Value; + Assert.StartsWith("MyProduct/1.0.0 OTel-OTLP-Exporter-Dotnet/", updatedUserAgent, StringComparison.OrdinalIgnoreCase); + Assert.NotEqual(initialUserAgent, updatedUserAgent); + } + + [Fact] + public void UserAgentProductIdentifier_Rfc7231Compliance_SpaceSeparatedTokens() + { + var options = new OtlpExporterOptions + { + UserAgentProductIdentifier = "MyProduct/1.0.0", + }; + + var userAgentHeader = options.StandardHeaders.FirstOrDefault(h => h.Key == "User-Agent").Value; + + // Should have two product tokens separated by a space + var tokens = userAgentHeader.Split(' '); + Assert.Equal(2, tokens.Length); + Assert.Equal("MyProduct/1.0.0", tokens[0]); + Assert.StartsWith("OTel-OTLP-Exporter-Dotnet/", tokens[1],StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(" ")] + public void UserAgentProductIdentifier_EmptyOrWhitespace_UsesDefaultUserAgent(string identifier) + { + var options = new OtlpExporterOptions + { + UserAgentProductIdentifier = identifier, + }; + + var userAgentHeader = options.StandardHeaders.FirstOrDefault(h => h.Key == "User-Agent").Value; + + // Should only contain the default exporter identifier, no leading space + Assert.StartsWith("OTel-OTLP-Exporter-Dotnet/", userAgentHeader, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(" ", userAgentHeader, StringComparison.OrdinalIgnoreCase); // No double spaces + } + + [Fact] + public void UserAgentProductIdentifier_MultipleProducts_CorrectFormat() + { + var options = new OtlpExporterOptions + { + UserAgentProductIdentifier = "MySDK/2.0.0 MyDistribution/1.0.0", + }; + + var userAgentHeader = options.StandardHeaders.FirstOrDefault(h => h.Key == "User-Agent").Value; + + Assert.StartsWith("MySDK/2.0.0 MyDistribution/1.0.0 OTel-OTLP-Exporter-Dotnet/", userAgentHeader, StringComparison.OrdinalIgnoreCase); + } } diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/UseOtlpExporterExtensionTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/UseOtlpExporterExtensionTests.cs index 424a9922cba..acc7b786e66 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/UseOtlpExporterExtensionTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/UseOtlpExporterExtensionTests.cs @@ -38,7 +38,7 @@ public void UseOtlpExporterDefaultTest() var exporterOptions = sp.GetRequiredService>().CurrentValue; #if NETFRAMEWORK || NETSTANDARD2_0 - Assert.Equal(new Uri(OtlpExporterOptions.DefaultHttpEndpoint), exporterOptions.DefaultOptions.Endpoint); + Assert.Equal(new Uri(options.DefaultHttpEndpoint), exporterOptions.DefaultOptions.Endpoint); #else Assert.Equal(new Uri(OtlpExporterOptions.DefaultGrpcEndpoint), exporterOptions.DefaultOptions.Endpoint); #endif