Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions src/OpenTelemetry.Api/Baggage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ namespace OpenTelemetry;
/// </remarks>
public readonly struct Baggage : IEquatable<Baggage>
{
private static readonly RuntimeContextSlot<BaggageHolder> RuntimeContextSlot = RuntimeContext.RegisterSlot<BaggageHolder>("otel.baggage");
private static readonly RuntimeContextSlot<BaggageHolder?> RuntimeContextSlot = RuntimeContext.RegisterSlot<BaggageHolder?>("otel.baggage");

private static readonly Dictionary<string, string> EmptyBaggage = [];
private static readonly bool UseSafeFlow = !IsUnsafeLegacyFlowEnabled();

private readonly Dictionary<string, string> baggage;

Expand Down Expand Up @@ -56,11 +58,35 @@ internal Baggage(Dictionary<string, string> baggage)
/// // the 'newKey1' change is lost.
/// Baggage.Current = newBaggage;
/// </code>
/// <para/>
/// By default, updates affect only the current execution context and contexts
/// subsequently forked from it. Changes made in an awaited asynchronous method
/// do not flow back to its caller.
/// <para/>
/// Setting the <c>OpenTelemetry.Baggage.AllowUnsafeLegacyFlow</c> AppContext
/// switch to <c>true</c> before the first use of <see cref="Baggage"/> restores
/// legacy sharing of updates across inherited contexts. An unset or
/// <c>false</c> switch keeps the isolated behavior.
/// Legacy sharing is insecure for independent operations because their baggage
/// can overwrite each other's. The setting is read once and is intended only
/// as a temporary migration aid.
/// </remarks>
public static Baggage Current
{
get => RuntimeContextSlot.Get()?.Baggage ?? default;
set => EnsureBaggageHolder().Baggage = value;
set
{
if (UseSafeFlow)
{
// Inherited contexts share the holder reference. Publish a replacement
// instead of mutating it so existing contexts keep their own baggage.
RuntimeContextSlot.Set(value.Count == 0 ? null : new BaggageHolder { Baggage = value });
}
else
{
EnsureBaggageHolder().Baggage = value;
}
}
}

/// <summary>
Expand Down Expand Up @@ -147,6 +173,11 @@ public static Dictionary<string, string>.Enumerator GetEnumerator(Baggage baggag
[SuppressMessage("roslyn", "RS0026", Justification = "TODO: fix APIs that violate the backcompt requirement - multiple overloads with optional parameters: https://github.com/dotnet/roslyn/blob/main/docs/Adding%20Optional%20Parameters%20in%20Public%20API.md.")]
public static Baggage SetBaggage(string name, string? value, Baggage baggage = default)
{
if (UseSafeFlow)
{
return Current = (baggage == default ? Current : baggage).SetBaggage(name, value);
}

var baggageHolder = EnsureBaggageHolder();
lock (baggageHolder)
{
Expand All @@ -166,6 +197,11 @@ public static Baggage SetBaggage(string name, string? value, Baggage baggage = d
[SuppressMessage("roslyn", "RS0026", Justification = "TODO: fix APIs that violate the backcompt requirement - multiple overloads with optional parameters: https://github.com/dotnet/roslyn/blob/main/docs/Adding%20Optional%20Parameters%20in%20Public%20API.md.")]
public static Baggage SetBaggage(IEnumerable<KeyValuePair<string, string?>> baggageItems, Baggage baggage = default)
{
if (UseSafeFlow)
{
return Current = (baggage == default ? Current : baggage).SetBaggage(baggageItems);
}

var baggageHolder = EnsureBaggageHolder();
lock (baggageHolder)
{
Expand All @@ -184,6 +220,11 @@ public static Baggage SetBaggage(IEnumerable<KeyValuePair<string, string?>> bagg
/// <remarks>Note: The <see cref="Baggage"/> returned will be set as the new <see cref="Current"/> instance.</remarks>
public static Baggage RemoveBaggage(string name, Baggage baggage = default)
{
if (UseSafeFlow)
{
return Current = (baggage == default ? Current : baggage).RemoveBaggage(name);
}

var baggageHolder = EnsureBaggageHolder();
lock (baggageHolder)
{
Expand All @@ -201,6 +242,11 @@ public static Baggage RemoveBaggage(string name, Baggage baggage = default)
/// <remarks>Note: The <see cref="Baggage"/> returned will be set as the new <see cref="Current"/> instance.</remarks>
public static Baggage ClearBaggage(Baggage baggage = default)
{
if (UseSafeFlow)
{
return Current = default;
}

var baggageHolder = EnsureBaggageHolder();
lock (baggageHolder)
{
Expand Down Expand Up @@ -423,6 +469,9 @@ public override int GetHashCode()
return hash;
}

private static bool IsUnsafeLegacyFlowEnabled()
=> AppContext.TryGetSwitch("OpenTelemetry.Baggage.AllowUnsafeLegacyFlow", out var enabled) && enabled;

private static BaggageHolder EnsureBaggageHolder()
{
var baggageHolder = RuntimeContextSlot.Get();
Expand Down
6 changes: 6 additions & 0 deletions src/OpenTelemetry.Api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ Notes](../../RELEASENOTES.md).

## Unreleased

* **Breaking Change**: Isolate ambient baggage updates between execution contexts.
Changes made in awaited async methods no longer update their caller's baggage.
Enable the `OpenTelemetry.Baggage.AllowUnsafeLegacyFlow` AppContext switch at
startup to temporarily restore insecure legacy sharing.
([#8042](https://github.com/open-telemetry/opentelemetry-dotnet/pull/8042))

## 1.20.1

Released 2026-Oct-20
Expand Down
86 changes: 86 additions & 0 deletions src/OpenTelemetry.Api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,92 @@ Baggage.ClearBaggage();
The recommended way to add Baggage is to use the `Baggage.SetBaggage()` API.
OpenTelemetry users should not use the `Activity.AddBaggage` method.

#### Baggage flow across asynchronous calls

By default, `Baggage.Current` flows into child execution contexts as a snapshot.
Assigning `Baggage.Current` or calling the static `SetBaggage`, `RemoveBaggage`,
or `ClearBaggage` methods updates the current execution context and contexts
subsequently forked from it. Previously forked contexts keep their own baggage.
The same behavior applies when a static method receives an explicit `Baggage`
argument. This describes the default `AsyncLocal` runtime context carrier;
applications that select another `RuntimeContext.ContextSlotType` retain that
carrier's propagation behavior.

Changes made inside an awaited asynchronous method do not update the caller's
baggage. To propagate enrichment back to a caller, return the updated baggage
and assign it in the caller's context:

```csharp
Baggage.Current = await EnrichBaggageAsync(Baggage.Current);
await SendRequestAsync();

static async Task<Baggage> EnrichBaggageAsync(Baggage baggage)
{
var tenantId = await LookupTenantIdAsync();
return baggage.SetBaggage("tenant.id", tenantId);
}
```

Returning a new `Baggage` value from an instance method still requires assigning
that value if it should become current. Synchronous helper calls update their
caller's current execution context as before.

##### Temporary compatibility option: insecure legacy flow

Earlier versions shared a mutable baggage holder between inherited execution
contexts. That allowed awaited helpers and parallel tasks to update their
caller's baggage. Applications that require time to migrate can restore that
behavior by enabling the `OpenTelemetry.Baggage.AllowUnsafeLegacyFlow`
AppContext switch before the first use of `Baggage`.
Comment thread
martincostello marked this conversation as resolved.
Resolved

For SDK-style .NET applications, add the switch to the application's project
file so the host configures it at startup:

```xml
<ItemGroup>
<RuntimeHostConfigurationOption
Include="OpenTelemetry.Baggage.AllowUnsafeLegacyFlow"
Value="true" />
</ItemGroup>
```

This generates an entry in the application's `.runtimeconfig.json`
`runtimeOptions.configProperties`. For .NET Framework applications, configure
the switch in the application's `App.config`:
Comment thread
martincostello marked this conversation as resolved.
Resolved

```xml
<configuration>
<runtime>
<AppContextSwitchOverrides
value="OpenTelemetry.Baggage.AllowUnsafeLegacyFlow=true" />
</runtime>
</configuration>
```

Applications can also set the switch in startup code before any component
uses `Baggage`:

```csharp
AppContext.SetSwitch("OpenTelemetry.Baggage.AllowUnsafeLegacyFlow", true);
```

An unset or `false` switch keeps the isolated behavior. The selected mode is
cached when `Baggage` initializes. Configure it at startup and restart the
application after changing it. SDK configuration, dependency injection, and
later `AppContext.SetSwitch` calls do not update the cached mode.

> [!WARNING]
> Legacy flow is insecure for independent operations. A write can change baggage
Comment thread
martincostello marked this conversation as resolved.
Resolved
> seen by an operation that is already running, its parent, or its siblings.
> This can mix baggage belonging to different users and propagate it with the
> wrong operation. The compatibility option restores this risk; it does not
> provide isolation between requests or messages. Use it only temporarily while
> migrating applications, then remove it.
This changes the default behavior of existing applications. The compatibility
option provides a migration path; enabling it does not make the new default
backward compatible.

## Introduction to OpenTelemetry .NET Tracing API

.NET runtime had `Activity` class for a long time, which was meant to be used
Expand Down
158 changes: 158 additions & 0 deletions test/OpenTelemetry.Api.Tests/BaggageConfigurationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

using System.Diagnostics;
#if NET
using System.Text.Json.Nodes;
#else
using System.Xml;
#endif

namespace OpenTelemetry.Tests;

public class BaggageConfigurationTests
{
private const string AppContextSwitch = "OpenTelemetry.Baggage.AllowUnsafeLegacyFlow";

[Theory]
[InlineData(null)]
[InlineData(false)]
[InlineData(true)]
public Task FlowModeIsSelectedAtProcessStartup(bool? enabled)
=> RunChildProcess(enabled);

private static async Task RunChildProcess(bool? appContextValue)
{
var directory = Path.Combine(Path.GetTempPath(), $"OpenTelemetry.BaggageTests-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
Comment thread
martincostello marked this conversation as resolved.
Resolved
try
{
await RunChildProcess(CreateStartInfo(directory, appContextValue), appContextValue);
}
finally
{
Directory.Delete(directory, recursive: true);
}
}

private static async Task RunChildProcess(ProcessStartInfo startInfo, bool? appContextValue)
Comment thread
Kielek marked this conversation as resolved.
Resolved
{
using var process = Process.Start(startInfo);
Assert.NotNull(process);
#if NET
var output = process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken);
var error = process.StandardError.ReadToEndAsync(TestContext.Current.CancellationToken);
#else
var output = process.StandardOutput.ReadToEndAsync();
var error = process.StandardError.ReadToEndAsync();
#endif

try
{
#if NET
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await process.WaitForExitAsync(timeout.Token);
#else
Assert.True(process.WaitForExit(30_000), "Baggage child test process timed out.");
#endif
Assert.True(
process.ExitCode == 0,
$"Baggage AppContext '{appContextValue?.ToString() ?? "<unset>"}' failed:\n{await output}\n{await error}");
}
finally
{
if (!process.HasExited)
{
process.Kill();
#if NET
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await process.WaitForExitAsync(timeout.Token);
#else
Assert.True(process.WaitForExit(5_000), "Baggage child test process did not exit after termination.");
#endif
}
}
}

private static ProcessStartInfo CreateStartInfo(string directory, bool? enabled)
{
var assemblyPath = typeof(BaggageTests).Assembly.Location;
#if NET
var config = JsonNode.Parse(File.ReadAllText(Path.ChangeExtension(assemblyPath, ".runtimeconfig.json")))!;
var options = config["runtimeOptions"]!;
var properties = options["configProperties"]?.AsObject();
if (properties == null)
{
properties = [];
options["configProperties"] = properties;
}

properties.Remove(AppContextSwitch);
if (enabled.HasValue)
{
properties[AppContextSwitch] = enabled.Value;
}

var configPath = Path.Combine(directory, "test.runtimeconfig.json");
File.WriteAllText(configPath, config.ToJsonString());
var startInfo = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet");
startInfo.ArgumentList.Add("exec");
startInfo.ArgumentList.Add("--runtimeconfig");
startInfo.ArgumentList.Add(configPath);
startInfo.ArgumentList.Add(assemblyPath);
startInfo.ArgumentList.Add("--filter-class");
startInfo.ArgumentList.Add(typeof(BaggageTests).FullName!);
#else
// Framework loads configuration next to the executable. Give the child
// its own copy without changing the parent's executable or configuration.
foreach (var file in Directory.EnumerateFiles(Path.GetDirectoryName(assemblyPath)!))
{
File.Copy(file, Path.Combine(directory, Path.GetFileName(file)));
}

var childAssemblyPath = Path.Combine(directory, Path.GetFileName(assemblyPath));
var configPath = childAssemblyPath + ".config";
var config = new XmlDocument { XmlResolver = null };
using (var reader = XmlReader.Create(configPath, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }))
{
config.Load(reader);
}

var runtime = config.SelectSingleNode("/configuration/runtime")!;
var overrides = (XmlElement?)runtime.SelectSingleNode("AppContextSwitchOverrides");
if (overrides == null)
{
overrides = config.CreateElement("AppContextSwitchOverrides");
runtime.AppendChild(overrides);
}

var switches = overrides.GetAttribute("value").Split(';')
.Where(value => !string.IsNullOrWhiteSpace(value)
&& !value.StartsWith(AppContextSwitch + "=", StringComparison.Ordinal))
.ToList();
if (enabled.HasValue)
{
switches.Add($"{AppContextSwitch}={enabled.Value}");
}

if (switches.Count == 0)
{
runtime.RemoveChild(overrides);
}
else
{
overrides.SetAttribute("value", string.Join(";", switches));
}

config.Save(configPath);
var startInfo = new ProcessStartInfo(childAssemblyPath)
{
Arguments = $"--filter-class {typeof(BaggageTests).FullName}",
};
#endif
startInfo.UseShellExecute = false;
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
return startInfo;
}
}
Loading
Loading