Skip to content

[API] Baggage - Spec alignment changes#7051

Open
nabutabu wants to merge 65 commits into
open-telemetry:mainfrom
nabutabu:mulitple-baggage-spec-reconciles
Open

[API] Baggage - Spec alignment changes#7051
nabutabu wants to merge 65 commits into
open-telemetry:mainfrom
nabutabu:mulitple-baggage-spec-reconciles

Conversation

@nabutabu
Copy link
Copy Markdown
Contributor

@nabutabu nabutabu commented Apr 8, 2026

Towards #5689

Fixes #5500

Changes

Adds a more verbose test suite that ensures spec alignment and then changes the BaggagePropogator to follow these tests

Merge requirement checklist

  • CONTRIBUTING guidelines followed (license requirements, nullable enabled, static analysis, etc.)
  • Unit tests added/updated
  • Appropriate CHANGELOG.md files updated for non-trivial changes
  • Changes in public API reviewed (if applicable)

@github-actions github-actions Bot added the pkg:OpenTelemetry.Api Issues related to OpenTelemetry.Api NuGet package label Apr 8, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 8, 2026

Codecov Report

❌ Patch coverage is 97.24771% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.01%. Comparing base (f3e872f) to head (a88b298).
⚠️ Report is 12 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...metry.Api/Context/Propagation/BaggagePropagator.cs 97.24% 3 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #7051      +/-   ##
==========================================
+ Coverage   89.86%   90.01%   +0.14%     
==========================================
  Files         276      276              
  Lines       14007    14193     +186     
==========================================
+ Hits        12588    12776     +188     
+ Misses       1419     1417       -2     
Flag Coverage Δ
unittests-Project-Experimental 89.80% <97.24%> (-0.04%) ⬇️
unittests-Project-Stable 89.88% <97.24%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...metry.Api/Context/Propagation/BaggagePropagator.cs 93.26% <97.24%> (+4.25%) ⬆️

... and 5 files with indirect coverage changes

@nabutabu
Copy link
Copy Markdown
Contributor Author

nabutabu commented Apr 9, 2026

What I found while writing and editing the tests:

  • on extract
    • keys are not currently being treated as literals according to spec and issue [BaggagePropagator] Do not encode and decode keys #5479. Also this discussion is important: baggage: Accept non-ASCII keys opentelemetry-go#4946
      • do not encode and decode keys at all
      • just drop invalid ones (anything containing CTLs, whitespace, DQUOTE, comma, semicolon, and backslash )
      • only happening for '+' sign, see results of ValidateValidTcharInKeyIsAcceptedOnExtract
    • values
      • incorrect decoding with '%20' was not converted to ' '
      • incorrect decoding '+' was incorrectly converted to ' '
  • on inject,
    • value encoding is incorrect (eg. ' ' -> '+' when it should be ' ' -> '%20')
      • specifically for '+' ValidateSpecialCharactersInjectionForValue
      • all characters that should pass through inject with no change: ValueValidBaggageOctetCharPassesThroughUnchanged

Before moving forward:

  • keep reviewing tests to ensure spec compliance
  • find what changes are needed for BaggagePropogator
    • bring up these changes in SIG, breaking change

@nabutabu
Copy link
Copy Markdown
Contributor Author

@martincostello Could I get a quick initial review on these tests before I start working on the changes for the propogator? Thanks!

@martincostello
Copy link
Copy Markdown
Member

Seems reasonable from a skim-read.

@nabutabu
Copy link
Copy Markdown
Contributor Author

nabutabu commented Apr 16, 2026

Raw Benchmark Data

Before my changes

| Method  | ItemCount | UseSpecialChars | HeaderStyle | Mean        | Error      | StdDev     | Median      | Gen0   | Gen1   | Allocated |
|-------- |---------- |---------------- |------------ |------------:|-----------:|-----------:|------------:|-------:|-------:|----------:|
| Extract | 1         | False           | Properties  |   120.85 ns |   2.441 ns |   6.301 ns |   120.32 ns | 0.0410 |      - |     344 B |
| Inject  | 1         | False           | Properties  |    76.96 ns |   1.564 ns |   3.300 ns |    76.70 ns | 0.0181 |      - |     152 B |
| Extract | 1         | False           | W3C         |   139.40 ns |   2.817 ns |   6.909 ns |   137.98 ns | 0.0410 |      - |     344 B |
| Inject  | 1         | False           | W3C         |    70.21 ns |   1.449 ns |   3.270 ns |    70.23 ns | 0.0181 |      - |     152 B |
| Extract | 1         | True            | Properties  |   578.58 ns |  24.091 ns |  71.033 ns |   602.58 ns | 0.0925 |      - |     776 B |
| Inject  | 1         | True            | Properties  |   238.99 ns |   5.649 ns |  16.116 ns |   236.78 ns | 0.0515 |      - |     432 B |
| Extract | 1         | True            | W3C         |   363.38 ns |   7.201 ns |  14.547 ns |   362.51 ns | 0.0925 |      - |     776 B |
| Inject  | 1         | True            | W3C         |   191.29 ns |   3.831 ns |   4.845 ns |   191.69 ns | 0.0515 |      - |     432 B |
| Extract | 5         | False           | Properties  |   413.19 ns |   7.479 ns |   6.245 ns |   413.25 ns | 0.1049 | 0.0005 |     880 B |
| Inject  | 5         | False           | Properties  |   236.02 ns |   4.759 ns |   6.825 ns |   236.45 ns | 0.0582 |      - |     488 B |
| Extract | 5         | False           | W3C         |   513.41 ns |  10.072 ns |  22.528 ns |   508.12 ns | 0.1049 |      - |     880 B |
| Inject  | 5         | False           | W3C         |   243.32 ns |   4.909 ns |  10.776 ns |   241.97 ns | 0.0582 |      - |     488 B |
| Extract | 5         | True            | Properties  | 1,382.50 ns |  27.499 ns |  54.281 ns | 1,376.08 ns | 0.3624 | 0.0019 |    3040 B |
| Inject  | 5         | True            | Properties  |   820.35 ns |  16.120 ns |  37.998 ns |   819.69 ns | 0.2298 |      - |    1928 B |
| Extract | 5         | True            | W3C         | 1,445.38 ns |  28.821 ns |  67.368 ns | 1,442.03 ns | 0.3624 | 0.0019 |    3040 B |
| Inject  | 5         | True            | W3C         |   814.16 ns |  16.300 ns |  39.367 ns |   810.93 ns | 0.2298 |      - |    1928 B |
| Extract | 20        | False           | Properties  | 1,501.17 ns |  29.935 ns |  64.439 ns | 1,494.76 ns | 0.4272 | 0.0076 |    3576 B |
| Inject  | 20        | False           | Properties  |   795.07 ns |  14.345 ns |  21.471 ns |   792.91 ns | 0.2384 | 0.0010 |    2000 B |
| Extract | 20        | False           | W3C         | 1,905.92 ns |  33.061 ns |  29.307 ns | 1,910.59 ns | 0.4272 | 0.0076 |    3576 B |
| Inject  | 20        | False           | W3C         |   802.50 ns |  16.015 ns |  29.684 ns |   804.33 ns | 0.2384 | 0.0010 |    2000 B |
| Extract | 20        | True            | Properties  | 5,123.01 ns | 100.644 ns | 165.361 ns | 5,155.75 ns | 1.4648 | 0.0305 |   12296 B |
| Inject  | 20        | True            | Properties  | 2,949.73 ns |  58.668 ns | 153.524 ns | 2,916.64 ns | 0.8163 | 0.0038 |    6832 B |
| Extract | 20        | True            | W3C         | 5,612.90 ns | 109.314 ns | 228.180 ns | 5,572.05 ns | 1.4648 | 0.0305 |   12296 B |
| Inject  | 20        | True            | W3C         | 2,889.92 ns |  57.324 ns | 138.443 ns | 2,892.45 ns | 0.8163 | 0.0038 |    6832 B |

After my changes

| Method  | ItemCount | UseSpecialChars | HeaderStyle | Mean        | Error     | StdDev     | Gen0   | Gen1   | Allocated |
|-------- |---------- |---------------- |------------ |------------:|----------:|-----------:|-------:|-------:|----------:|
| Extract | 1         | False           | Properties  |    86.80 ns |  1.528 ns |   2.716 ns | 0.0411 |      - |     344 B |
| Inject  | 1         | False           | Properties  |    69.21 ns |  1.419 ns |   2.767 ns | 0.0229 |      - |     192 B |
| Extract | 1         | False           | W3C         |    36.90 ns |  0.761 ns |   1.207 ns | 0.0067 |      - |      56 B |
| Inject  | 1         | False           | W3C         |    69.73 ns |  1.413 ns |   2.619 ns | 0.0229 |      - |     192 B |
| Extract | 1         | True            | Properties  |   198.06 ns |  3.940 ns |   9.132 ns | 0.0801 |      - |     672 B |
| Inject  | 1         | True            | Properties  |   224.86 ns |  4.485 ns |   9.460 ns | 0.0832 |      - |     696 B |
| Extract | 1         | True            | W3C         |    34.72 ns |  0.723 ns |   1.444 ns | 0.0067 |      - |      56 B |
| Inject  | 1         | True            | W3C         |   216.02 ns |  4.326 ns |   9.220 ns | 0.0832 |      - |     696 B |
| Extract | 5         | False           | Properties  |   299.65 ns |  5.966 ns |  13.096 ns | 0.1049 | 0.0005 |     880 B |
| Inject  | 5         | False           | Properties  |   234.77 ns |  4.698 ns |  10.411 ns | 0.0820 |      - |     688 B |
| Extract | 5         | False           | W3C         |    67.33 ns |  1.381 ns |   2.230 ns | 0.0067 |      - |      56 B |
| Inject  | 5         | False           | W3C         |   227.87 ns |  4.554 ns |   6.532 ns | 0.0823 |      - |     688 B |
| Extract | 5         | True            | Properties  |   820.86 ns | 16.333 ns |  36.193 ns | 0.3004 | 0.0019 |    2520 B |
| Inject  | 5         | True            | Properties  |   932.39 ns | 18.606 ns |  33.551 ns | 0.3834 | 0.0010 |    3208 B |
| Extract | 5         | True            | W3C         |    68.69 ns |  1.408 ns |   2.191 ns | 0.0067 |      - |      56 B |
| Inject  | 5         | True            | W3C         |   963.07 ns | 19.278 ns |  35.732 ns | 0.3834 |      - |    3208 B |
| Extract | 20        | False           | Properties  | 1,034.73 ns | 20.502 ns |  37.488 ns | 0.4272 | 0.0076 |    3576 B |
| Inject  | 20        | False           | Properties  |   842.20 ns | 16.734 ns |  29.308 ns | 0.3347 | 0.0010 |    2800 B |
| Extract | 20        | False           | W3C         |   205.17 ns |  4.105 ns |   5.192 ns | 0.0067 |      - |      56 B |
| Inject  | 20        | False           | W3C         |   794.50 ns | 15.605 ns |  28.535 ns | 0.3347 | 0.0010 |    2800 B |
| Extract | 20        | True            | Properties  | 3,523.02 ns | 70.014 ns | 131.504 ns | 1.2093 | 0.0305 |   10136 B |
| Inject  | 20        | True            | Properties  | 3,519.56 ns | 69.175 ns | 139.738 ns | 1.4153 | 0.0076 |   11856 B |
| Extract | 20        | True            | W3C         |   219.32 ns |  4.306 ns |   5.894 ns | 0.0067 |      - |      56 B |
| Inject  | 20        | True            | W3C         | 3,276.47 ns | 63.636 ns | 125.612 ns | 1.4153 | 0.0076 |   11856 B |

Notable Changes

Before

| Extract | 20        | True            | W3C         | 5,612.90 ns | 109.314 ns | 228.180 ns | 5,572.05 ns | 1.4648 | 0.0305 |   12296 B |
| Inject  | 20        | True            | W3C         | 2,889.92 ns |  57.324 ns | 138.443 ns | 2,892.45 ns | 0.8163 | 0.0038 |    6832 B |

After

| Extract | 20        | True            | W3C         |   219.32 ns |  4.306 ns |   5.894 ns | 0.0067 |      - |      56 B |
| Inject  | 20        | True            | W3C         | 3,276.47 ns | 63.636 ns | 125.612 ns | 1.4153 | 0.0076 |   11856 B |

@nabutabu
Copy link
Copy Markdown
Contributor Author

nabutabu commented Apr 17, 2026

@Kielek as mentioned in #7009 Baggage needs more tests and requirements that need to be fixed. This PR is a collection of those missing changes that the spec mentions.
Build currently fails because OWS and properties tests fail but those should be addressed by #7009 and will pass once that is merged to main and main is merged to this

This is a bigger LOC change so let me also give a more verbose description of the changes. Lot of this will quote my previous comment:

Keys

  • Extract:
    • Keys are no longer being encoded but are checked for validity. Spec does not allow:

    Delimiters are chosen from the set of US-ASCII visual characters not allowed in a token (DQUOTE and "(),/:;<=>?@[]{}").

  • Inject:
    • Key is now using Uri.EscapeDataString EncodeKey which means delimiters are now being escaped. Look at test KeyWithDelimiterCharIsPercentEncodedOnInject
      Edit on above line made after commit: efd1a11

Values

  • Extract:
    • No changes
  • Inject
    • Is encoded if any characters outside the baggage-octet are seen. Checks are performed using a ReadOnlySpan (similar to keys during Extract and IsValidKey) to ensure performance

    baggage-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
    ; US-ASCII characters excluding CTLs,
    ; whitespace, DQUOTE, comma, semicolon,
    ; and backslash

Let me know if you'd like more information.

@nabutabu nabutabu marked this pull request as ready for review April 17, 2026 20:34
@nabutabu nabutabu requested a review from a team as a code owner April 17, 2026 20:34
@nabutabu nabutabu changed the title add tests that check spec compatibility [BaggagePropogator] Spec alignment chanegs Apr 17, 2026
@nabutabu nabutabu changed the title [BaggagePropogator] Spec alignment chanegs [API] Baggage - Spec alignment chanegs Apr 17, 2026
@nabutabu nabutabu changed the title [API] Baggage - Spec alignment chanegs [API] Baggage - Spec alignment changes Apr 17, 2026
@Kielek
Copy link
Copy Markdown
Member

Kielek commented May 5, 2026

@nabutabu, after merges with main some tests started failing. Could you please check what wrong happened?

What is more, at the current state (post main merge) I have asked Codex/gpt5.5 to review changes.

The results


[P1] Value encoding is not W3C percent encoding

File: src/OpenTelemetry.Api/Context/Propagation/BaggagePropagator.cs

Lines: 283-292

The encoder treats raw percent signs and non-ASCII characters as safe. W3C requires U+0025 and any value code point outside baggage-octet to be percent-encoded as UTF-8 bytes. This can emit invalid headers, corrupt values such as x%20y on downstream extraction, and let non-ASCII data exceed the 8192-byte limit while the character count still passes.

[P1] UrlDecode still converts plus to space

File: src/OpenTelemetry.Api/Context/Propagation/BaggagePropagator.cs

Lines: 326-330

DecodeIfNeeded calls WebUtility.UrlDecode whenever a percent sign is present. That API has form-url semantics and maps + to space, but + is a valid baggage-octet under W3C/RFC3986. A header like key=a+b%20c extracts as a b c instead of a+b c. This needs a percent decoder that only decodes %HH UTF-8 sequences and leaves + untouched.

[P2] Valid OWS around keys is rejected

File: src/OpenTelemetry.Api/Context/Propagation/BaggagePropagator.cs

Lines: 209-214

The PR validates the raw key span before trimming. W3C allows OWS around commas and around the equals separator, so valid headers like key1=value1, key2=value2 or serverNode = DF%2028 are now dropped. Trim SP/HTAB around the key before IsValidKey, then store the trimmed key without decoding.

[P2] Invalid keys are silently renamed

File: src/OpenTelemetry.Api/Context/Propagation/BaggagePropagator.cs

Lines: 283-292

EncodeKey percent-encodes non-token key characters, but extraction now preserves percent sequences in keys. That means app baggage key 1 propagates as key%201 and comes back under a different key; the existing environment-carrier baggage test still expects the old round trip. Since W3C defines key as token and has no key percent-decoding rule, invalid keys should be dropped or rejected before propagation rather than silently rewritten.

Spec References

@nabutabu
Copy link
Copy Markdown
Contributor Author

nabutabu commented May 5, 2026

Im on it, will update as soon as I can!

@nabutabu
Copy link
Copy Markdown
Contributor Author

nabutabu commented May 5, 2026

[P1] Value encoding is not W3C percent encoding

The encoder was using negative sets (like InvalidCharArray and InvalidValueArray) which did not incorporate non-ascii values. I have changed the propogator to instead use positive sets to clearly indicate allowed values. Non-ASCII characters are encoded as UTF-8 bytes per RFC 3986 §2.1

[P1] UrlDecode still converts plus to space

A custom decoder is needed when Extracting values. This is because:

  • WebUtility.UrlDecode
    • has form-url semantics and maps + to space, but + is a valid baggage-octet
  • Uri.UnescapeDataString
    • For malformed sequences like %GG or %2 (incomplete), Uri.UnescapeDataString leaves them as-is rather than replacing with U+FFFD. The spec explicitly requires replacement. ValidateMalformedPercentSequenceInValueIsReplacedWithReplacementCharacter checks this now
    • A sequence like %C3%28 is valid percent-encoding but invalid UTF-8 (incomplete two-byte sequence). Uri.UnescapeDataString behaviour here is undefined and version-dependent, it may throw a UriFormatException on some inputs, which would be caught by Extract's outer try/catch and silently drop the entire baggage header.
    • this has also been done before and then reverted in [api-baggage] revert space encoding change #5687

[P2] Valid OWS around keys is rejected

Keys should be trimmed before being dropped. Changed.

[P2] Invalid keys are silently renamed

This is a known consequence of this PR and is exactly why BaggagePropogatorFuzzTests were changed. Inject and Extract have asymmetric rules: Inject percent-encodes characters that are invalid token char (e.g. a space in key 1 becomes key%201 on the wire), but Extract treats % as a valid token char and does not decode keys. So an application that sets key key 1 gets back key key%201 after a round trip a different key under a different name.

Benchmarks

| Method  | ItemCount | UseSpecialChars | HeaderStyle          | Mean       | Error    | StdDev    | Median     | Gen0   | Gen1   | Allocated |
|-------- |---------- |---------------- |--------------------- |-----------:|---------:|----------:|-----------:|-------:|-------:|----------:|
| Extract | 1         | False           | Clean                |   105.7 ns |  5.38 ns |  15.60 ns |   103.7 ns | 0.0411 |      - |     344 B |
| Inject  | 1         | False           | Clean                |   155.4 ns |  6.11 ns |  18.03 ns |   162.7 ns | 0.0267 |      - |     224 B |
| Extract | 1         | False           | WithOWSAndProperties |   222.3 ns |  4.05 ns |   3.79 ns |   222.7 ns | 0.0410 |      - |     344 B |
| Inject  | 1         | False           | WithOWSAndProperties |   106.3 ns |  8.54 ns |  25.18 ns |   100.7 ns | 0.0267 |      - |     224 B |
| Extract | 1         | True            | Clean                |   279.2 ns | 24.84 ns |  73.26 ns |   250.7 ns | 0.0811 |      - |     680 B |
| Inject  | 1         | True            | Clean                |   277.2 ns | 11.91 ns |  35.11 ns |   260.6 ns | 0.0963 |      - |     808 B |
| Extract | 1         | True            | WithOWSAndProperties |   245.9 ns |  8.89 ns |  25.21 ns |   241.2 ns | 0.0811 |      - |     680 B |
| Inject  | 1         | True            | WithOWSAndProperties |   290.4 ns | 13.90 ns |  41.00 ns |   281.4 ns | 0.0966 |      - |     808 B |
| Extract | 5         | False           | Clean                |   337.4 ns | 11.57 ns |  32.46 ns |   331.4 ns | 0.1049 | 0.0005 |     880 B |
| Inject  | 5         | False           | Clean                |   319.7 ns | 14.65 ns |  42.96 ns |   296.8 ns | 0.1011 |      - |     848 B |
| Extract | 5         | False           | WithOWSAndProperties |   434.2 ns | 18.56 ns |  54.71 ns |   434.1 ns | 0.1049 | 0.0005 |     880 B |
| Inject  | 5         | False           | WithOWSAndProperties |   310.0 ns | 13.85 ns |  40.82 ns |   290.1 ns | 0.1011 |      - |     848 B |
| Extract | 5         | True            | Clean                |   920.1 ns | 30.33 ns |  84.05 ns |   897.8 ns | 0.3052 | 0.0019 |    2560 B |
| Inject  | 5         | True            | Clean                | 1,177.7 ns | 53.63 ns | 158.13 ns | 1,114.8 ns | 0.4520 |      - |    3784 B |
| Extract | 5         | True            | WithOWSAndProperties |   966.2 ns | 30.25 ns |  84.83 ns |   965.5 ns | 0.3052 | 0.0019 |    2560 B |
| Inject  | 5         | True            | WithOWSAndProperties | 1,141.0 ns | 36.85 ns | 106.91 ns | 1,093.4 ns | 0.4520 |      - |    3784 B |
| Extract | 20        | False           | Clean                | 1,244.1 ns | 48.13 ns | 139.62 ns | 1,179.8 ns | 0.4272 | 0.0076 |    3576 B |
| Inject  | 20        | False           | Clean                | 1,062.5 ns | 24.72 ns |  70.92 ns | 1,080.2 ns | 0.4110 | 0.0010 |    3440 B |
| Extract | 20        | False           | WithOWSAndProperties | 1,498.3 ns | 46.30 ns | 134.34 ns | 1,448.1 ns | 0.4272 | 0.0076 |    3576 B |
| Inject  | 20        | False           | WithOWSAndProperties | 1,074.6 ns | 36.91 ns | 107.08 ns | 1,035.1 ns | 0.4101 |      - |    3440 B |
| Extract | 20        | True            | Clean                | 3,466.0 ns | 91.59 ns | 259.82 ns | 3,440.4 ns | 1.2283 | 0.0305 |   10296 B |
| Inject  | 20        | True            | Clean                | 4,068.6 ns | 92.27 ns | 264.74 ns | 4,150.8 ns | 1.6937 | 0.0153 |   14176 B |
| Extract | 20        | True            | WithOWSAndProperties | 3,769.4 ns | 84.82 ns | 241.99 ns | 3,749.0 ns | 1.2283 | 0.0305 |   10296 B |
| Inject  | 20        | True            | WithOWSAndProperties | 4,098.6 ns | 96.67 ns | 280.47 ns | 4,153.2 ns | 1.6937 | 0.0153 |   14176 B |
  • Positive sets vs negative sets are essentially the same on clean paths The difference between the two PR versions on clean headers is within noise (99ns vs 106ns) for a single item. The ContainsAnyExcept fast path exits immediately when all characters are valid, so the positive set approach has no measurable overhead on the common case.
  • Special chars Extract is significantly faster than pre-PR. Roughly 35–40% faster. The custom decoder replacing WebUtility.UrlDecode is the likely driver — the custom decoder avoids string allocations for the common case and avoids the overhead of the general-purpose URL decoding machinery.
  • Inject with special chars is slower and allocates more. This is expected and happens because the old code wasn't encoding non-ASCII characters or raw % signs at all, it was emitting invalid headers cheaply. Current code does UTF-8 encoding non-ASCII characters produces more bytes on the wire, which means more StringBuilder appends and more allocation.

Will commit the changes soon to represent the positive set changes

@Kielek
Copy link
Copy Markdown
Member

Kielek commented May 6, 2026

Some minor, but still worth to fix comment related to UTF-16 chars. I am not sure how I could find this manually, but...


The updated BaggagePropagator encoder percent-encodes BMP non-ASCII values correctly, but it still iterates one UTF-16 char at a time. For a non-BMP Unicode scalar value such as U+1F600, the string contains a surrogate pair. The current implementation encodes each surrogate independently through Encoding.UTF8, which produces replacement characters instead of the single UTF-8 sequence for the scalar value.

Expected wire encoding for U+1F600 is:

%F0%9F%98%80

This matters because the value is corrupted after an inject/extract round trip, and the emitted header does not follow the W3C requirement to percent-encode values outside baggage-octet using UTF-8 octets.

W3C References

Relevant W3C points:

  • value = *baggage-octet, where baggage-octet is limited to specific ASCII ranges.
  • Values outside baggage-octet MUST be percent-encoded.
  • The spec's non-ASCII example encodes Amelie with an accented e as UTF-8 percent-encoded bytes.

For U+1F600, the value is outside baggage-octet, so it must be encoded as its UTF-8 octets: %F0%9F%98%80, not as two independently encoded UTF-16 surrogate values.

Test Patch

diff --git a/test/OpenTelemetry.Api.Tests/Context/Propagation/BaggagePropagatorTests.cs b/test/OpenTelemetry.Api.Tests/Context/Propagation/BaggagePropagatorTests.cs
--- a/test/OpenTelemetry.Api.Tests/Context/Propagation/BaggagePropagatorTests.cs
+++ b/test/OpenTelemetry.Api.Tests/Context/Propagation/BaggagePropagatorTests.cs
@@ -957,21 +957,24 @@ public void ValidateNonAsciiValueIsUtf8PercentEncodedOnInject()
-    [Fact]
-    public void RoundTripNonAsciiValuePreservesOriginalString()
+    [Theory]
+    [InlineData("caf\u00E9")]
+    [InlineData("emoji-\U0001F600")]
+    public void RoundTripNonAsciiValuePreservesOriginalString(string value)
     {
         var propagationContext = new PropagationContext(
             default,
-            new Baggage(new Dictionary<string, string> { { "key", "caf\u00E9" } }));
+            new Baggage(new Dictionary<string, string> { { "key", value } }));
 
         var carrier = new Dictionary<string, string>();
         this.baggage.Inject(propagationContext, carrier, Setter);
 
         var extracted = this.baggage.Extract(default, carrier, Getter).Baggage.GetBaggage();
         var entry = Assert.Single(extracted);
         Assert.Equal("key", entry.Key);
-        Assert.Equal("caf\u00E9", entry.Value);
+        Assert.Equal(value, entry.Value);
     }
 
     [Theory]
     [InlineData("\u00E9", "%C3%A9")]
     [InlineData("\u4E2D", "%E4%B8%AD")]
     [InlineData("\u00A3", "%C2%A3")]
+    [InlineData("\U0001F600", "%F0%9F%98%80")]
     public void ValidateNonAsciiUtf8EncodingIsCorrect(string character, string expectedEncoding)

@Kielek
Copy link
Copy Markdown
Member

Kielek commented May 6, 2026

And hopefully last one, related to the Invalid keys are silently renamed. It can be a bit controversial so it might you might rise it as a separate PR. But if you decide to include it here, I will be also happy. It seems as a breaking behavioral change, so I would suggest additional review here.

I think the best way, will be to go with go-lang approach, but there is no-hard requirement for this.
Worth to mention, that Rust is following similar approach.

Following notes supported by Codex


Why

Before this PR, .NET used form-url encoding/decoding for both keys and values:

  • key 1=value 1 injected as key+1=value+1
  • key+1=value+1 extracted back as key 1=value 1
  • key%28%293=value extracted as key()3=value

That gave .NET-to-.NET round-trip compatibility for invalid keys, but it was not W3C-compatible.

The updated PR correctly stops decoding keys during extraction. However, injection still percent-encodes invalid key characters. That creates silent key mutation:

App baggage key Header emitted Downstream extracted key
key 1 key%201=value key%201
tenant/id tenant%2Fid=42 tenant%2Fid
key()3 key%28%293=value key%28%293

This is worse than dropping because the baggage item appears to survive propagation, but under a different key. It can also cause collisions, for example local keys tenant/id and tenant%2Fid both propagating as tenant%2Fid.

Spec basis

W3C defines baggage keys as token:

token is the RFC 7230 token grammar:

That grammar allows only ASCII token characters. Spaces, /, :, ;, =, ?, ,, parentheses, brackets, braces, quotes, backslash, and non-ASCII characters are not valid key characters.

Also, % is itself a valid token character. So key%201 is a valid literal key, not an encoded form of key 1. W3C percent-encoding applies to baggage values, not keys.

Cross-language behavior

OpenTelemetry Go follows the behavior I think .NET should adopt:

  • invalid keys are rejected/skipped on parse
  • invalid keys are ignored when serializing to a header
  • keys are not percent-encoded or percent-decoded
  • values are percent-encoded/decoded

Relevant Go source:

Java extraction also skips invalid keys and does not decode keys; only values are decoded:

Recommendation

For W3C baggage propagation, .NET should:

  1. Stop calling EncodeKey for injection.
  2. Validate keys against the W3C/RFC7230 token grammar before writing them.
  3. Omit invalid-key baggage entries from the outgoing header.
  4. Keep extraction behavior that validates keys and does not percent-decode them.

Suggested release-note wording:

Baggage propagation now follows the W3C baggage key syntax. Keys in the baggage header are RFC 7230 tokens and are not URL-decoded. Baggage entries with invalid W3C keys, such as keys containing spaces, non-ASCII characters, or separators like /, :, ;, =, ?, ,, (, or ), may be omitted from outgoing headers. Use token-safe keys such as user.id, user_id, or tenant-id.

@martincostello
Copy link
Copy Markdown
Member

martincostello commented May 6, 2026

I can't make diff comments at the moment due to a GitHub outage so I'll put them here in a PR comment:

  1. Maybe make the (c >> 4) & 0xF-related code a helper method to avoid the repetition. Happened across some code (1, 2) while working on something else that is doing the same thing.
  2. Could simplify IsHexDigit a little to be like this:
char.IsAsciiDigit(c) || c is (>= 'A' and <= 'F') or (>= 'a' and <= 'f');

Comment thread src/OpenTelemetry.Api/Context/Propagation/BaggagePropagator.cs Outdated
Comment thread src/OpenTelemetry.Api/Context/Propagation/BaggagePropagator.cs Outdated
@nabutabu
Copy link
Copy Markdown
Contributor Author

nabutabu commented May 6, 2026

And hopefully last one, related to the Invalid keys are silently renamed. It can be a bit controversial so it might you might rise it as a separate PR. But if you decide to include it here, I will be also happy. It seems as a breaking behavioral change, so I would suggest additional review here.

I think the best way, will be to go with go-lang approach, but there is no-hard requirement for this.
Worth to mention, that Rust is following similar approach.

I agree, the Go-lang approach here is the best one.

This is the trade-off based on which we have to make a decision, on Inject we can either:

  • Encode keys (current behavior of main and this PR)
    OR
  • Drop invalid keys using IsValidKey

The change would look like:

var encodedKey = EncodeKey(item.Key);

would be replaced by

if (!IsValidKey) {
    continue;
}

This change to follow the Go-implementation actually does not violate any of the issues that we are addressing as part of this PR:

Unrelated but worth mentioning, @cijothomas mentioned here that there are plans to sunset Propogators, but there's no timeline on when that happens.

Regardless of whether we use the Go implementation or not, I think it's good idea to keep the test suite and skip tests wherever needed based on our decision.

Edit: Go lang approach requires KeyWithDelimiterCharIsPercentEncodedOnInject to be skipped

Edit: Commit: 9ccd2c1 changes current implementation to follow Go. Let me know if we wish to change it back!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pkg:OpenTelemetry.Api Issues related to OpenTelemetry.Api NuGet package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expand test coverage for baggage encoding

3 participants