diff --git a/src/OpenTelemetry.Api/Context/Propagation/BaggagePropagator.cs b/src/OpenTelemetry.Api/Context/Propagation/BaggagePropagator.cs index 3a0dca61219..29924eae74b 100644 --- a/src/OpenTelemetry.Api/Context/Propagation/BaggagePropagator.cs +++ b/src/OpenTelemetry.Api/Context/Propagation/BaggagePropagator.cs @@ -1,10 +1,6 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 -#if NET -using System.Diagnostics.CodeAnalysis; -#endif -using System.Net; using System.Text; using OpenTelemetry.Internal; @@ -20,9 +16,6 @@ public class BaggagePropagator : TextMapPropagator private const int MaxBaggageLength = 8192; private const int MaxBaggageItems = 180; - private static readonly char[] EqualSignSeparator = ['=']; - private static readonly char[] CommaSignSeparator = [',']; - /// public override ISet Fields => new HashSet { BaggageHeaderName }; @@ -52,7 +45,7 @@ public override PropagationContext Extract(PropagationContext context, T carr var baggageCollection = getter(carrier, BaggageHeaderName); if (baggageCollection?.Any() ?? false) { - if (TryExtractBaggage([.. baggageCollection], out var baggage)) + if (PercentEncodingHelper.TryExtractBaggage([.. baggageCollection], out var baggage)) { return new PropagationContext(context.ActivityContext, new Baggage(baggage!)); } @@ -97,77 +90,12 @@ public override void Inject(PropagationContext context, T carrier, Action? baggage) - { - int baggageLength = -1; - bool done = false; - Dictionary? baggageDictionary = null; - - foreach (var item in baggageCollection) - { - if (done) - { - break; - } - - if (string.IsNullOrEmpty(item)) - { - continue; - } - - foreach (var pair in item.Split(CommaSignSeparator)) - { - baggageLength += pair.Length + 1; // pair and comma - - if (baggageLength >= MaxBaggageLength || baggageDictionary?.Count >= MaxBaggageItems) - { - done = true; - break; - } - -#if NET - if (pair.IndexOf('=', StringComparison.Ordinal) < 0) -#else - if (pair.IndexOf('=') < 0) -#endif - { - continue; - } - - var parts = pair.Split(EqualSignSeparator, 2); - if (parts.Length != 2) - { - continue; - } - - var key = WebUtility.UrlDecode(parts[0]); - var value = WebUtility.UrlDecode(parts[1]); - - if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(value)) - { - continue; - } - - baggageDictionary ??= []; - - baggageDictionary[key] = value; - } - } - - baggage = baggageDictionary; - return baggageDictionary != null; - } } diff --git a/src/OpenTelemetry.Api/OpenTelemetry.Api.csproj b/src/OpenTelemetry.Api/OpenTelemetry.Api.csproj index a3a2895ee3e..ed92e7e7750 100644 --- a/src/OpenTelemetry.Api/OpenTelemetry.Api.csproj +++ b/src/OpenTelemetry.Api/OpenTelemetry.Api.csproj @@ -15,6 +15,7 @@ + diff --git a/src/OpenTelemetry/CHANGELOG.md b/src/OpenTelemetry/CHANGELOG.md index 8ac2f28309d..9f2b37a7f34 100644 --- a/src/OpenTelemetry/CHANGELOG.md +++ b/src/OpenTelemetry/CHANGELOG.md @@ -18,6 +18,11 @@ Released 2025-Oct-01 to a single `MeterProvider`, as required by the OpenTelemetry specification. ([#6458](https://github.com/open-telemetry/opentelemetry-dotnet/pull/6458)) +* Added decoding of the `OTEL_RESOURCE_ATTRIBUTES` variable according to the specification, + adhering to the [W3C Baggage](https://github.com/w3c/baggage/blob/main/baggage/HTTP_HEADER_FORMAT.md) + format. + ([#6461](https://github.com/open-telemetry/opentelemetry-dotnet/pull/6461)) + * Added `FormatMessage` configuration option to self-diagnostics feature. When set to `true` (default is false), log messages will be formatted by replacing placeholders with actual parameter values for improved readability. diff --git a/src/OpenTelemetry/Resources/OtelEnvResourceDetector.cs b/src/OpenTelemetry/Resources/OtelEnvResourceDetector.cs index 468b37ca1a9..72591889e75 100644 --- a/src/OpenTelemetry/Resources/OtelEnvResourceDetector.cs +++ b/src/OpenTelemetry/Resources/OtelEnvResourceDetector.cs @@ -2,14 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 using Microsoft.Extensions.Configuration; +using OpenTelemetry.Internal; namespace OpenTelemetry.Resources; internal sealed class OtelEnvResourceDetector : IResourceDetector { public const string EnvVarKey = "OTEL_RESOURCE_ATTRIBUTES"; - private const char AttributeListSplitter = ','; - private const char AttributeKeyValueSplitter = '='; private readonly IConfiguration configuration; @@ -35,16 +34,12 @@ private static List> ParseResourceAttributes(string { var attributes = new List>(); - string[] rawAttributes = resourceAttributes.Split(AttributeListSplitter); - foreach (string rawKeyValuePair in rawAttributes) + if (PercentEncodingHelper.TryExtractBaggage([resourceAttributes], out var baggage) && baggage != null) { - string[] keyValuePair = rawKeyValuePair.Split(AttributeKeyValueSplitter); - if (keyValuePair.Length != 2) + foreach (var kvp in baggage) { - continue; + attributes.Add(new KeyValuePair(kvp.Key, kvp.Value)); } - - attributes.Add(new KeyValuePair(keyValuePair[0].Trim(), keyValuePair[1].Trim())); } return attributes; diff --git a/src/Shared/PercentEncodingHelper.cs b/src/Shared/PercentEncodingHelper.cs new file mode 100644 index 00000000000..9c94e560ef3 --- /dev/null +++ b/src/Shared/PercentEncodingHelper.cs @@ -0,0 +1,146 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NET +using System.Diagnostics.CodeAnalysis; +#endif +using System.Text; +using System.Text.RegularExpressions; + +namespace OpenTelemetry.Internal; + +/// +/// Helper methods for percent-encoding and decoding baggage values. +/// See https://w3c.github.io/baggage/. +/// +internal static partial class PercentEncodingHelper +{ + private const int MaxBaggageLength = 8192; + private const int MaxBaggageItems = 180; + private const char KeyValueSplitter = '='; + private const char ListSplitter = ','; + + internal static bool TryExtractBaggage( + string[] baggageCollection, +#if NET + [NotNullWhen(true)] +#endif + out Dictionary? baggage) + { + Dictionary? baggageDictionary = null; + int baggageLength = -1; // Start with -1 to account for no leading comma on first item + + foreach (var baggageList in baggageCollection.Where(h => !string.IsNullOrEmpty(h))) + { + foreach (string keyValuePair in baggageList.Split(ListSplitter)) + { + baggageLength += keyValuePair.Length + 1; // pair length + comma + if (ExceedsMaxBaggageLimits(baggageLength, baggageDictionary?.Count)) + { + baggage = baggageDictionary; + return baggageDictionary != null; + } +#if NET + var indexOfFirstEquals = keyValuePair.IndexOf(KeyValueSplitter, StringComparison.Ordinal); +#else + var indexOfFirstEquals = keyValuePair.IndexOf(KeyValueSplitter); +#endif + if (indexOfFirstEquals < 0) + { + continue; + } + + var splitKeyValue = keyValuePair.Split([KeyValueSplitter], 2); + var key = splitKeyValue[0].Trim(); + var value = splitKeyValue[1].Trim(); + + if (!IsValidKeyValuePair(key, value)) + { + continue; + } + + var decodedValue = PercentDecodeBaggage(value); + + baggageDictionary ??= []; + baggageDictionary[key] = decodedValue; + } + } + + baggage = baggageDictionary; + return baggageDictionary != null; + } + + /// + /// As per the specification, only the value is percent-encoded. + /// "Uri.EscapeDataString" encodes code points which are not required to be percent-encoded. + /// + /// The baggage key. + /// The baggage value. + /// The percent-encoded baggage item. + internal static string PercentEncodeBaggage(string key, string value) => $"{key.Trim()}={Uri.EscapeDataString(value.Trim())}"; + + private static string PercentDecodeBaggage(string baggageEncoded) + { + var bytes = new List(); + for (int i = 0; i < baggageEncoded.Length; i++) + { + if (baggageEncoded[i] == '%' && i + 2 < baggageEncoded.Length && IsHex(baggageEncoded[i + 1]) && IsHex(baggageEncoded[i + 2])) + { + var hex = baggageEncoded.AsSpan(i + 1, 2); +#if NET + bytes.Add(byte.Parse(hex, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture)); +#else + bytes.Add(Convert.ToByte(hex.ToString(), 16)); +#endif + + i += 2; + } + else if (baggageEncoded[i] == '%') + { + return baggageEncoded; // Bad percent triplet -> return original value + } + else + { + if (!IsBaggageOctet(baggageEncoded[i])) + { + return baggageEncoded; // non-encoded character not baggage octet encoded -> return original value + } + + bytes.Add((byte)baggageEncoded[i]); + } + } + + return new UTF8Encoding(false, false).GetString(bytes.ToArray()); + } + +#if NET + [GeneratedRegex(@"^[!#$%&'*+\-\.^_`|~0-9A-Z]+$", RegexOptions.IgnoreCase)] + private static partial Regex TokenRegex(); +#else + +#pragma warning disable SA1201 // A field should not follow a method + private static readonly Regex TokenRegexField = new( + @"^[!#$%&'*+\-\.^_`|~0-9A-Z]+$", RegexOptions.IgnoreCase | RegexOptions.Compiled); +#pragma warning restore SA1201 // A field should not follow a method + + private static Regex TokenRegex() => TokenRegexField; +#endif + + private static bool ExceedsMaxBaggageLimits(int currentLength, int? currentItemCount) => + currentLength >= MaxBaggageLength || currentItemCount >= MaxBaggageItems; + + private static bool IsValidKeyValuePair(string key, string value) => + !string.IsNullOrEmpty(key) && !string.IsNullOrEmpty(value) && TokenRegex().IsMatch(key); + + private static bool IsHex(char c) => + (c >= '0' && c <= '9') || + (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F'); + + private static bool IsBaggageOctet(char c) => + c == 0x21 || + (c >= 0x23 && c <= 0x2B) || + (c >= 0x2D && c <= 0x3A) || + (c >= 0x3C && c <= 0x5B) || + (c >= 0x5D && c <= 0x7E); +} diff --git a/test/OpenTelemetry.Api.Tests/Context/Propagation/BaggagePropagatorTests.cs b/test/OpenTelemetry.Api.Tests/Context/Propagation/BaggagePropagatorTests.cs index 13c21c28cec..e50758dea59 100644 --- a/test/OpenTelemetry.Api.Tests/Context/Propagation/BaggagePropagatorTests.cs +++ b/test/OpenTelemetry.Api.Tests/Context/Propagation/BaggagePropagatorTests.cs @@ -152,14 +152,14 @@ public void ValidateSpecialCharsBaggageExtraction() Assert.Equal(3, actualBaggage.Count); - Assert.True(actualBaggage.ContainsKey("key 1")); - Assert.Equal("value 1", actualBaggage["key 1"]); + Assert.True(actualBaggage.ContainsKey("key+1")); + Assert.Equal("value+1", actualBaggage["key+1"]); Assert.True(actualBaggage.ContainsKey("key2")); Assert.Equal("!x_x,x-x&x(x\");:", actualBaggage["key2"]); - Assert.True(actualBaggage.ContainsKey("key()3")); - Assert.Equal("value()!&;:", actualBaggage["key()3"]); + Assert.True(actualBaggage.ContainsKey("key%28%293")); + Assert.Equal("value()!&;:", actualBaggage["key%28%293"]); } [Fact] @@ -204,6 +204,6 @@ public void ValidateSpecialCharsBaggageInjection() this.baggage.Inject(propagationContext, carrier, Setter); Assert.Single(carrier); - Assert.Equal("key+1=value+1,key2=!x_x%2Cx-x%26x(x%22)%3B%3A", carrier[BaggagePropagator.BaggageHeaderName]); + Assert.Equal("key 1=value%201,key2=%21x_x%2Cx-x%26x%28x%22%29%3B%3A", carrier[BaggagePropagator.BaggageHeaderName]); } } diff --git a/test/OpenTelemetry.Api.Tests/PercentEncodingHelperTests.cs b/test/OpenTelemetry.Api.Tests/PercentEncodingHelperTests.cs new file mode 100644 index 00000000000..129c1debeda --- /dev/null +++ b/test/OpenTelemetry.Api.Tests/PercentEncodingHelperTests.cs @@ -0,0 +1,92 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using OpenTelemetry.Internal; +using Xunit; + +namespace OpenTelemetry.Api.Tests; + +#pragma warning disable CA1062 // Validate arguments of public methods +public class PercentEncodingHelperTests +{ + [Theory] + [InlineData(new string[] { "key1=val1,key2=val2", "key3=val3", "key4=val4" }, new string[] { "key1", "key2", "key3", "key4" }, new string[] { "val1", "val2", "val3", "val4" })] // Multiple headers + [InlineData(new string[] { "key1=val%201,key2=val2" }, new string[] { "key1", "key2" }, new string[] { "val 1", "val2" })] + [InlineData(new string[] { "key1,key2=val2" }, new string[] { "key2" }, new string[] { "val2" })] + [InlineData(new string[] { "key=Am%C3%A9lie" }, new string[] { "key" }, new string[] { "Am\u00E9lie" })] // Valid percent-encoded value + [InlineData(new string[] { "key1=val1,key2=val2==3" }, new string[] { "key1", "key2" }, new string[] { "val1", "val2==3" })] // Valid value with equal sign + [InlineData(new string[] { "key1=,key2=val2" }, new string[] { "key2" }, new string[] { "val2" })] // Empty value for key1 + [InlineData(new string[] { "=val1,key2=val2" }, new string[] { "key2" }, new string[] { "val2" })] // Empty key for key1 + [InlineData(new string[] { "Am\u00E9lie=val" }, new string[] { }, new string[] { }, false)] // Invalid key + [InlineData(new string[] { "key=invalid%encoding" }, new string[] { "key" }, new string[] { "invalid%encoding" })] // Invalid value + [InlineData(new string[] { "key=v1+v2" }, new string[] { "key" }, new string[] { "v1+v2" })] +#if NET + [InlineData(new string[] { "key=a%E0%80Am%C3%A9lie" }, new string[] { "key" }, new string[] { "a\uFFFD\uFFFDAm\u00E9lie" })] +#else + [InlineData(new string[] { "key=a%E0%80Am%C3%A9lie" }, new string[] { "key" }, new string[] { "a\uFFFDAm\u00E9lie" })] +#endif + public void ValidateBaggageExtraction(string[] baggage, string[] expectedKey, string[] expectedValue, bool canExtractExpected = true) + { + var canExtract = PercentEncodingHelper.TryExtractBaggage(baggage, out var extractedBaggage); + + Assert.Equal(canExtractExpected, canExtract); + if (!canExtractExpected) + { + Assert.Null(extractedBaggage); + return; + } + + Assert.Equal(expectedKey.Length, extractedBaggage!.Count); + for (int i = 0; i < expectedKey.Length; i++) + { + Assert.True(extractedBaggage!.ContainsKey(expectedKey[i])); + Assert.Equal(expectedValue[i], extractedBaggage[expectedKey[i]]); + } + } + + [Theory] + [InlineData("key1", "value 1", "key1=value%201")] + [InlineData("key2", "!x_x,x-x&x(x\");:", "key2=%21x_x%2Cx-x%26x%28x%22%29%3B%3A")] + [InlineData("key2", """!x_x,x-x&x(x\");:""", "key2=%21x_x%2Cx-x%26x%28x%5C%22%29%3B%3A")] + public void ValidateBaggageEncoding(string key, string value, string expectedEncoded) + { + var encodedValue = PercentEncodingHelper.PercentEncodeBaggage(key, value); + Assert.Equal(expectedEncoded, encodedValue); + } + + [Fact] + public void ValidateBaggageExtraction_ExceedsItemLimit() + { + var baggageItems = new List(); + for (int i = 0; i < 200; i++) + { + baggageItems.Add($"key{i}=value{i}"); + } + + var baggage = string.Join(",", baggageItems); + var canExtract = PercentEncodingHelper.TryExtractBaggage([baggage], out var extractedBaggage); + + Assert.True(canExtract); + Assert.NotNull(extractedBaggage); + Assert.Equal(180, extractedBaggage!.Count); // Max 180 items + for (int i = 0; i < 180; i++) + { + Assert.True(extractedBaggage!.ContainsKey($"key{i}")); + Assert.Equal($"value{i}", extractedBaggage[$"key{i}"]); + } + } + + [Fact] + public void ValidateBaggageExtraction_ExceedsLengthLimit() + { + var baggage = $"name={new string('x', 8186)},clientId=1234"; + var canExtract = PercentEncodingHelper.TryExtractBaggage([baggage], out var extractedBaggage); + + Assert.True(canExtract); + Assert.NotNull(extractedBaggage); + + Assert.Single(extractedBaggage!); // Only one item should be extracted due to length limit + Assert.Equal("name", extractedBaggage!.Keys.First()); + Assert.Equal(new string('x', 8186), extractedBaggage["name"]); + } +} diff --git a/test/OpenTelemetry.Tests/Resources/OtelEnvResourceDetectorTests.cs b/test/OpenTelemetry.Tests/Resources/OtelEnvResourceDetectorTests.cs index 2f2c337d67e..de69070d7d7 100644 --- a/test/OpenTelemetry.Tests/Resources/OtelEnvResourceDetectorTests.cs +++ b/test/OpenTelemetry.Tests/Resources/OtelEnvResourceDetectorTests.cs @@ -6,6 +6,7 @@ namespace OpenTelemetry.Resources.Tests; +#pragma warning disable CA1062 // Validate arguments of public methods public sealed class OtelEnvResourceDetectorTests : IDisposable { public OtelEnvResourceDetectorTests() @@ -37,26 +38,24 @@ public void OtelEnvResource_NullEnvVar() Assert.Equal(Resource.Empty, resource); } - [Fact] - public void OtelEnvResource_WithEnvVar_1() - { - // Arrange - var envVarValue = "Key1=Val1,Key2=Val2"; - Environment.SetEnvironmentVariable(OtelEnvResourceDetector.EnvVarKey, envVarValue); - var resource = new OtelEnvResourceDetector( - new ConfigurationBuilder().AddEnvironmentVariables().Build()) - .Detect(); - - // Assert - Assert.NotEqual(Resource.Empty, resource); - Assert.Contains(new KeyValuePair("Key1", "Val1"), resource.Attributes); - } - - [Fact] - public void OtelEnvResource_WithEnvVar_2() + [Theory] + [InlineData("key1=val1,key2=val2", new string[] { "key1", "key2" }, new string[] { "val1", "val2" })] + [InlineData("key1,key2=val2", new string[] { "key2" }, new string[] { "val2" })] + [InlineData("key=Am%C3%A9lie", new string[] { "key" }, new string[] { "Am\u00E9lie" })] // Valid percent-encoded value + [InlineData("key1=val1,key2=val2==3", new string[] { "key1", "key2" }, new string[] { "val1", "val2==3" })] // Valid value with equal sign + [InlineData("key1=,key2=val2", new string[] { "key2" }, new string[] { "val2" })] // Empty value for key1 + [InlineData("=val1,key2=val2", new string[] { "key2" }, new string[] { "val2" })] // Empty key for key1 + [InlineData("Am\u00E9lie=val", new string[] { }, new string[] { })] // Invalid key + [InlineData("key=invalid%encoding", new string[] { "key" }, new string[] { "invalid%encoding" })] // Invalid value + [InlineData("key=v1+v2", new string[] { "key" }, new string[] { "v1+v2" })] +#if NET + [InlineData("key=a%E0%80Am%C3%A9lie", new string[] { "key" }, new string[] { "a\uFFFD\uFFFDAm\u00E9lie" })] +#else + [InlineData("key=a%E0%80Am%C3%A9lie", new string[] { "key" }, new string[] { "a\uFFFDAm\u00E9lie" })] +#endif + public void OtelEnvResource_EnvVar_Validation(string envVarValue, string[] expectedKeys, string[] expectedValues) { // Arrange - var envVarValue = "Key1,Key2=Val2"; Environment.SetEnvironmentVariable(OtelEnvResourceDetector.EnvVarKey, envVarValue); var resource = new OtelEnvResourceDetector( new ConfigurationBuilder().AddEnvironmentVariables().Build()) @@ -64,8 +63,12 @@ public void OtelEnvResource_WithEnvVar_2() // Assert Assert.NotEqual(Resource.Empty, resource); - Assert.Single(resource.Attributes); - Assert.Contains(new KeyValuePair("Key2", "Val2"), resource.Attributes); + Assert.Equal(expectedKeys.Length, expectedValues.Length); + Assert.Equal(expectedKeys.Length, resource.Attributes.Count()); + for (int i = 0; i < expectedKeys.Length; i++) + { + Assert.Equal(expectedKeys.Zip(expectedValues, (k, v) => new KeyValuePair(k, v)), resource.Attributes); + } } [Fact]