From af82185074d8e0e6b8417b8a389936e061c73a53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Kie=C5=82kowicz?= Date: Fri, 16 Oct 2026 11:31:31 +0200 Subject: [PATCH 1/2] [Api] Reuse tracers for equal array-valued scope tags Compare and hash scalar array tag values by their elements and snapshot the arrays so caller mutations cannot change cached tracer keys. Give ActivitySource its own array copies to keep its public tags separate from the cache key. Cover repeated lookups, differing values and types, mutation, shape, and cyclic input. Discovered by Anthropic's OSS Scanner, as bug ANT-2026-PFSTNMUG. --- src/OpenTelemetry.Api/Trace/TracerProvider.cs | 91 +++++++++++-- .../Trace/TracerTests.cs | 126 ++++++++++++++++++ 2 files changed, 205 insertions(+), 12 deletions(-) diff --git a/src/OpenTelemetry.Api/Trace/TracerProvider.cs b/src/OpenTelemetry.Api/Trace/TracerProvider.cs index 98abff3b015..1a2620edee8 100644 --- a/src/OpenTelemetry.Api/Trace/TracerProvider.cs +++ b/src/OpenTelemetry.Api/Trace/TracerProvider.cs @@ -101,7 +101,7 @@ public Tracer GetTracer( var activitySourceOptions = new System.Diagnostics.ActivitySourceOptions(key.Name) { Version = key.Version, - Tags = key.Tags, + Tags = key.GetActivitySourceTags(), TelemetrySchemaUrl = key.SchemaUrl, }; @@ -182,6 +182,10 @@ public override int GetHashCode() } } + // ActivitySource exposes its tags, so its arrays must not be shared with the cache key. + internal KeyValuePair[]? GetActivitySourceTags() + => GetOrderedTags(this.Tags); + private static bool AreTagsEqual( KeyValuePair[]? tags1, KeyValuePair[]? tags2) @@ -207,19 +211,44 @@ private static bool AreTagsEqual( } // Compare values - if (kvp1.Value is null) + if (!AreValuesEqual(kvp1.Value, kvp2.Value)) { - if (kvp2.Value is not null) - { - return false; - } + return false; + } + } + + return true; + } + + private static bool AreValuesEqual(object? value1, object? value2) + { + if (value1 is not Array array1 || value2 is not Array array2) + { + return Equals(value1, value2); + } + + if (array1.GetType() != array2.GetType() || array1.Length != array2.Length) + { + return false; + } + + for (var dimension = 0; dimension < array1.Rank; dimension++) + { + if (array1.GetLength(dimension) != array2.GetLength(dimension) + || array1.GetLowerBound(dimension) != array2.GetLowerBound(dimension)) + { + return false; } - else + } + + var enumerator1 = array1.GetEnumerator(); + var enumerator2 = array2.GetEnumerator(); + while (enumerator1.MoveNext() && enumerator2.MoveNext()) + { + // Compare scalar elements without traversing arbitrary object graphs. + if (!Equals(enumerator1.Current, enumerator2.Current)) { - if (!kvp1.Value.Equals(kvp2.Value)) - { - return false; - } + return false; } } @@ -246,7 +275,7 @@ private static int GetTagsHashCode( #endif if (kvp.Value != null) { - hash = (hash * 31) + kvp.Value.GetHashCode(); + hash = (hash * 31) + GetValueHashCode(kvp.Value); } } } @@ -254,6 +283,35 @@ private static int GetTagsHashCode( return hash; } + private static int GetValueHashCode(object value) + { + if (value is not Array array) + { + return value.GetHashCode(); + } + +#if NET + HashCode hash = default; + foreach (var item in array) + { + hash.Add(item); + } + + return hash.ToHashCode(); +#else + var hash = 0; + unchecked + { + foreach (var item in array) + { + hash = (hash * 31) + (item?.GetHashCode() ?? 0); + } + } + + return hash; +#endif + } + private static KeyValuePair[]? GetOrderedTags( IEnumerable>? tags) { @@ -263,6 +321,15 @@ private static int GetTagsHashCode( } var orderedTagList = new List>(tags); + for (var i = 0; i < orderedTagList.Count; i++) + { + // Snapshot array values so caller mutations cannot change a cached key. + if (orderedTagList[i].Value is Array array) + { + orderedTagList[i] = new(orderedTagList[i].Key, array.Clone()); + } + } + orderedTagList.Sort((left, right) => { // First compare by key diff --git a/test/OpenTelemetry.Api.Tests/Trace/TracerTests.cs b/test/OpenTelemetry.Api.Tests/Trace/TracerTests.cs index 62ed7e0a9c7..4ce73cd928f 100644 --- a/test/OpenTelemetry.Api.Tests/Trace/TracerTests.cs +++ b/test/OpenTelemetry.Api.Tests/Trace/TracerTests.cs @@ -468,6 +468,132 @@ public void GetTracer_WithSameTags_ReturnsSameInstance() Assert.Same(tracer1, tracer2); } + [Fact] + public void GetTracer_WithEqualArrayTags_ReturnsSameInstance() + { + using var tracerProvider = new TestTracerProvider(); + Tracer? firstTracer = null; + + for (var i = 0; i < 10; i++) + { + // Each lookup supplies newly allocated arrays with the same values. +#pragma warning disable CA1825 // The cache must also match distinct empty arrays. +#pragma warning disable CA1861 // Each lookup must supply fresh arrays to exercise value equality. + KeyValuePair[] tags = + [ + new("strings", new string?[] { "eu", null, "us" }), + new("booleans", new[] { true, false }), + new("integers", new[] { 1, 2 }), + new("longs", new[] { 1L, 2L }), + new("doubles", new[] { 1.0, double.NaN }), + new("floats", new[] { 1.0f, 2.0f }), + new("bytes", new byte[] { 1, 2 }), + new("shorts", new short[] { 1, 2 }), + new("chars", new[] { 'a', 'b' }), + new("empty", new string[0]), + ]; +#pragma warning restore CA1861 // Each lookup must supply fresh arrays to exercise value equality. +#pragma warning restore CA1825 // The cache must also match distinct empty arrays. + + var tracer = tracerProvider.GetTracer("test", "1.0.0", tags); + firstTracer ??= tracer; + Assert.Same(firstTracer, tracer); + } + + Assert.Single(tracerProvider.Tracers!); + } + + [Theory] + [InlineData(new[] { "eu", "us" }, new[] { "eu", "uk" })] + [InlineData(new[] { "eu", "us" }, new[] { "us", "eu" })] + [InlineData(new[] { "eu", "us" }, new[] { "eu" })] + [InlineData(new[] { "eu" }, "eu")] + [InlineData(new[] { "eu" }, null)] + [InlineData(new[] { 1, 2 }, new[] { 1L, 2L })] + [InlineData(new int[0], new long[0])] + public void GetTracer_WithDifferentArrayTags_ReturnsDifferentInstances(object? value1, object? value2) + { + using var tracerProvider = new TestTracerProvider(); + var tracer1 = tracerProvider.GetTracer("test", "1.0.0", tags: [new("regions", value1)]); + var tracer2 = tracerProvider.GetTracer("test", "1.0.0", tags: [new("regions", value2)]); + + Assert.NotSame(tracer1, tracer2); + } + + [Fact] + public void GetTracer_WithArrayTags_SnapshotsCallerValues() + { + using var tracerProvider = new TestTracerProvider(); + var value = new[] { "eu", "us" }; + KeyValuePair[] tags = [new("regions", value)]; + var tracer1 = tracerProvider.GetTracer("test", "1.0.0", tags); + + value[1] = "uk"; + var tracer2 = tracerProvider.GetTracer("test", "1.0.0", tags); + + value[1] = "us"; + var tracer3 = tracerProvider.GetTracer("test", "1.0.0", tags); + + Assert.NotSame(tracer1, tracer2); + Assert.Same(tracer1, tracer3); + Assert.Equal(2, tracerProvider.Tracers!.Count); + + var sourceValue = Assert.IsType(Assert.Single(tracer1.ActivitySource!.Tags!).Value); + Assert.NotSame(value, sourceValue); + Assert.Equal("eu", sourceValue[0]); + Assert.Equal("us", sourceValue[1]); + } + + [Fact] + public void GetTracer_WithArrayTags_SourceMutationDoesNotChangeCacheKey() + { + using var tracerProvider = new TestTracerProvider(); + var value = new[] { "eu", "us" }; + KeyValuePair[] tags = [new("regions", value)]; + var tracer1 = tracerProvider.GetTracer("test", "1.0.0", tags); + + var sourceValue = Assert.IsType(Assert.Single(tracer1.ActivitySource!.Tags!).Value); + sourceValue[1] = "uk"; + + var tracer2 = tracerProvider.GetTracer("test", "1.0.0", tags); + + Assert.Same(tracer1, tracer2); + Assert.Single(tracerProvider.Tracers!); + Assert.Equal("us", value[1]); + } + + [Fact] + public void GetTracer_WithMultidimensionalArrayTags_PreservesShape() + { + using var tracerProvider = new TestTracerProvider(); + var value = Array.CreateInstance(typeof(int), 1, 2); + value.SetValue(1, 0, 0); + value.SetValue(2, 0, 1); + var differentShape = Array.CreateInstance(typeof(int), 2, 1); + differentShape.SetValue(1, 0, 0); + differentShape.SetValue(2, 1, 0); + var tracer1 = tracerProvider.GetTracer("test", "1.0.0", tags: [new("values", value)]); + var tracer2 = tracerProvider.GetTracer("test", "1.0.0", tags: [new("values", value.Clone())]); + var tracer3 = tracerProvider.GetTracer("test", "1.0.0", tags: [new("values", differentShape)]); + + Assert.Same(tracer1, tracer2); + Assert.NotSame(tracer1, tracer3); + } + + [Fact] + public void GetTracer_WithSelfReferentialArrayTag_DoesNotRecurse() + { + using var tracerProvider = new TestTracerProvider(); + var value = new object[1]; + value[0] = value; + + var tracer1 = tracerProvider.GetTracer("test", "1.0.0", tags: [new("values", value)]); + var tracer2 = tracerProvider.GetTracer("test", "1.0.0", tags: [new("values", value)]); + + Assert.Same(tracer1, tracer2); + Assert.Single(tracerProvider.Tracers!); + } + [Fact] public void GetTracer_WithoutTags_ReturnsSameInstance() { From 64b8179a40b8504438bb32f3aa161241dfe8c564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Kie=C5=82kowicz?= Date: Fri, 16 Oct 2026 13:18:55 +0200 Subject: [PATCH 2/2] [Api] Add changelog entry for array-valued tracer tags Complete the Api changelog with the real fix PR link, https://github.com/open-telemetry/opentelemetry-dotnet/pull/7960. Discovered by Anthropic's OSS Scanner, as bug ANT-2026-PFSTNMUG. --- src/OpenTelemetry.Api/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/OpenTelemetry.Api/CHANGELOG.md b/src/OpenTelemetry.Api/CHANGELOG.md index 7c036aa12c4..383a05d2a9c 100644 --- a/src/OpenTelemetry.Api/CHANGELOG.md +++ b/src/OpenTelemetry.Api/CHANGELOG.md @@ -6,6 +6,10 @@ Notes](../../RELEASENOTES.md). ## Unreleased +* Fixed `TracerProvider.GetTracer` caching a new tracer for each call with equal + array-valued scope tags. + ([#7960](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7960)) + ## 1.20.0 Released 2026-Oct-15