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]