Skip to content

Commit fcd5236

Browse files
[OpenTelemetry] Fix metric storage leak (#7466)
1 parent d141db4 commit fcd5236

4 files changed

Lines changed: 208 additions & 36 deletions

File tree

src/OpenTelemetry/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ Notes](../../RELEASENOTES.md).
2727
* Added support for a Schema URL on `Resource` instances.
2828
([#7472](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7472))
2929

30+
* Fixed a metric storage leak that occurred when meters and instruments were
31+
repeatedly created and disposed.
32+
([#7466](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7466))
33+
3034
## 1.16.0
3135

3236
Released 2026-Jun-10

src/OpenTelemetry/Metrics/Reader/MetricReaderExt.cs

Lines changed: 92 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ namespace OpenTelemetry.Metrics;
1313
/// </summary>
1414
public abstract partial class MetricReader
1515
{
16-
private readonly HashSet<string> metricStreamNames = new(StringComparer.OrdinalIgnoreCase);
16+
private readonly Dictionary<string, int> metricStreamNames = new(StringComparer.OrdinalIgnoreCase);
1717
private readonly ConcurrentDictionary<MetricStreamIdentity, Metric?> instrumentIdentityToMetric = new();
1818
private readonly Lock instrumentCreationLock = new();
19+
private readonly ConcurrentQueue<int> availableMetricIndices = new();
20+
1921
private int metricLimit;
2022
private int cardinalityLimit;
2123
private Metric?[] metrics = [];
@@ -28,11 +30,11 @@ internal static void DeactivateMetric(Metric metric)
2830
{
2931
if (metric.Active)
3032
{
31-
// TODO: This will cause the metric to be removed from the storage
32-
// array during the next collect/export. If this happens often we
33-
// will run out of storage. Would it be better instead to set the
34-
// end time on the metric and keep it around so it can be
35-
// reactivated?
33+
// Note: This causes the metric to be removed from the storage array
34+
// during the next collect/export. The freed storage slot is then
35+
// reused for a subsequently published instrument (see
36+
// TryReserveMetricIndex), so repeatedly creating and disposing
37+
// meters/instruments does not exhaust the metric storage.
3638
metric.Active = false;
3739

3840
OpenTelemetrySdkEventSource.Log.MetricInstrumentDeactivated(
@@ -67,8 +69,7 @@ internal virtual List<Metric> AddMetricWithNoViews(Instrument instrument)
6769
return [existingMetric];
6870
}
6971

70-
var index = ++this.metricIndex;
71-
if (index >= this.metricLimit)
72+
if (!this.TryReserveMetricIndex(out var index))
7273
{
7374
OpenTelemetrySdkEventSource.Log.MetricInstrumentIgnored(metricStreamIdentity.InstrumentName, metricStreamIdentity.MeterName, "Maximum allowed Metric streams for the provider exceeded.", "Use MeterProviderBuilder.AddView to drop unused instruments. Or use MeterProviderBuilder.SetMaxMetricStreams to configure MeterProvider to allow higher limit.");
7475
return [];
@@ -86,6 +87,9 @@ internal virtual List<Metric> AddMetricWithNoViews(Instrument instrument)
8687
}
8788
catch (NotSupportedException nse)
8889
{
90+
// The reserved slot was never populated, so return it for reuse.
91+
this.availableMetricIndices.Enqueue(index);
92+
8993
// TODO: This allocates string even if none listening.
9094
// Could be improved with separate Event.
9195
// Also the message could call out what Instruments
@@ -158,8 +162,7 @@ internal virtual List<Metric> AddMetricWithViews(Instrument instrument, List<Met
158162
continue;
159163
}
160164

161-
var index = ++this.metricIndex;
162-
if (index >= this.metricLimit)
165+
if (!this.TryReserveMetricIndex(out var index))
163166
{
164167
OpenTelemetrySdkEventSource.Log.MetricInstrumentIgnored(metricStreamIdentity.InstrumentName, metricStreamIdentity.MeterName, "Maximum allowed Metric streams for the provider exceeded.", "Use MeterProviderBuilder.AddView to drop unused instruments. Or use MeterProviderBuilder.SetMaxMetricStreams to configure MeterProvider to allow higher limit.");
165168
}
@@ -221,15 +224,25 @@ private bool TryGetExistingMetric(in MetricStreamIdentity metricStreamIdentity,
221224

222225
private void CreateOrUpdateMetricStreamRegistration(in MetricStreamIdentity metricStreamIdentity)
223226
{
224-
if (!this.metricStreamNames.Add(metricStreamIdentity.MetricStreamName))
227+
if (this.metricStreamNames.TryGetValue(metricStreamIdentity.MetricStreamName, out var count))
225228
{
226-
// TODO: If a metric is deactivated and then reactivated we log the
227-
// same warning as if it was a duplicate.
229+
// TODO: This can be a false positive. The registration is only
230+
// released when the metric is removed during a collection cycle.
231+
// If an instrument is deactivated and an instrument with the same
232+
// stream name is re-created before the next collect, the stale
233+
// registration is still present and this logs a duplicate/conflict
234+
// warning even though there is no actual conflict.
228235
OpenTelemetrySdkEventSource.Log.DuplicateMetricInstrument(
229236
metricStreamIdentity.InstrumentName,
230237
metricStreamIdentity.MeterName,
231238
"Metric instrument has the same name as an existing one but differs by description, unit, or instrument type. Measurements from this instrument will still be exported but may result in conflicts.",
232239
"Either change the name of the instrument or use MeterProviderBuilder.AddView to resolve the conflict.");
240+
241+
this.metricStreamNames[metricStreamIdentity.MetricStreamName] = count + 1;
242+
}
243+
else
244+
{
245+
this.metricStreamNames[metricStreamIdentity.MetricStreamName] = 1;
233246
}
234247
}
235248

@@ -254,7 +267,7 @@ private Batch<Metric> GetMetricsBatch()
254267

255268
if (!metric.Active)
256269
{
257-
this.RemoveMetric(ref metric);
270+
this.RemoveMetric(ref metric, i);
258271
}
259272
}
260273
}
@@ -268,26 +281,77 @@ private Batch<Metric> GetMetricsBatch()
268281
}
269282
}
270283

271-
private void RemoveMetric(ref Metric? metric)
284+
private void RemoveMetric(ref Metric? metric, int index)
272285
{
273-
// TODO: This logic removes the metric. If the same
274-
// metric is published again we will create a new metric
275-
// for it. If this happens often we will run out of
276-
// storage. Instead, should we keep the metric around
277-
// and set a new start time + reset its data if it comes
278-
// back?
279-
280286
OpenTelemetrySdkEventSource.Log.MetricInstrumentRemoved(metric!.Name, metric.MeterName);
281287

282-
// Note: This is using TryUpdate and NOT TryRemove because there is a
283-
// race condition. If a metric is deactivated and then reactivated in
284-
// the same collection cycle
285-
// instrumentIdentityToMetric[metric.InstrumentIdentity] may already
286-
// point to the new activated metric and not the old deactivated one.
287-
this.instrumentIdentityToMetric.TryUpdate(metric.InstrumentIdentity, null, metric);
288+
// Note: This removes the entry (key and value) so the dictionary does
289+
// not grow without bound as instruments are repeatedly created and
290+
// disposed. A value-matching removal is used to guard against a race
291+
// condition: if a metric is deactivated and then reactivated in the
292+
// same collection cycle, instrumentIdentityToMetric[metric.InstrumentIdentity]
293+
// may already point to the new (active) metric. In that case the entry's
294+
// value no longer equals the old metric, the removal is a no-op, and the
295+
// new metric is retained.
296+
var item = new KeyValuePair<MetricStreamIdentity, Metric?>(metric.InstrumentIdentity, metric);
297+
#if NET
298+
this.instrumentIdentityToMetric.TryRemove(item);
299+
#else
300+
ICollection<KeyValuePair<MetricStreamIdentity, Metric?>> instrumentIdentityToMetric = this.instrumentIdentityToMetric;
301+
instrumentIdentityToMetric.Remove(item);
302+
#endif
303+
304+
// Release the stream-name registration for this metric. When the last
305+
// metric using the name is removed the entry is dropped so the
306+
// dictionary does not grow without bound as instruments are repeatedly
307+
// created and disposed with recycled storage slots.
308+
var metricStreamName = metric.InstrumentIdentity.MetricStreamName;
309+
lock (this.instrumentCreationLock)
310+
{
311+
if (this.metricStreamNames.TryGetValue(metricStreamName, out var count))
312+
{
313+
if (count <= 1)
314+
{
315+
this.metricStreamNames.Remove(metricStreamName);
316+
}
317+
else
318+
{
319+
this.metricStreamNames[metricStreamName] = count - 1;
320+
}
321+
}
322+
}
288323

289324
// Note: metric is a reference to the array storage so
290325
// this clears the metric out of the array.
291326
metric = null;
327+
328+
// Make the freed slot available for reuse by a future instrument. This
329+
// must happen after the slot has been cleared above so that a concurrent
330+
// AddMetricWith*Views call observes a null slot before writing to it.
331+
this.availableMetricIndices.Enqueue(index);
332+
}
333+
334+
private bool TryReserveMetricIndex(out int index)
335+
{
336+
// Note: This is always called while holding instrumentCreationLock, so
337+
// access to metricIndex does not need additional synchronization.
338+
339+
// Reuse a slot freed by a previously removed (deactivated) metric, if
340+
// one is available, before consuming a brand new slot.
341+
if (this.availableMetricIndices.TryDequeue(out index))
342+
{
343+
return true;
344+
}
345+
346+
var newIndex = this.metricIndex + 1;
347+
if (newIndex >= this.metricLimit)
348+
{
349+
index = -1;
350+
return false;
351+
}
352+
353+
this.metricIndex = newIndex;
354+
index = newIndex;
355+
return true;
292356
}
293357
}

test/OpenTelemetry.Extensions.Hosting.Tests/OpenTelemetryMetricsBuilderExtensionsTests.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -235,11 +235,6 @@ public void ReloadOfMetricsViaIConfigurationWithExportCleanupTest(bool useWithMe
235235

236236
AssertSingleMetricWithLongSum(exportedItems);
237237

238-
var duplicateMetricInstrumentEvents = eventListener.Messages.Where((e) => e.EventId == 38);
239-
240-
// Note: We currently log a duplicate warning anytime a metric is reactivated.
241-
Assert.Single(duplicateMetricInstrumentEvents);
242-
243238
var metricInstrumentDeactivatedEvents = eventListener.Messages.Where((e) => e.EventId == 52);
244239

245240
Assert.Single(metricInstrumentDeactivatedEvents);

test/OpenTelemetry.Tests/Metrics/MeterProviderSdkTests.cs

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
using System.Diagnostics.Metrics;
5+
using System.Reflection;
56
using OpenTelemetry.Internal;
67
using OpenTelemetry.Tests;
78

@@ -40,7 +41,7 @@ public void BuilderTypeDoesNotChangeTest()
4041
[InlineData(true, true)]
4142
[InlineData(false, false)]
4243
[InlineData(true, false)]
43-
public void TransientMeterExhaustsMetricStorageTest(bool withView, bool forceFlushAfterEachTest)
44+
public void TransientMeterMetricStorageIsReclaimedOnCollectTest(bool withView, bool forceFlushAfterEachTest)
4445
{
4546
using var eventListener = new TestEventListener(OpenTelemetrySdkEventSource.Log);
4647

@@ -73,7 +74,10 @@ public void TransientMeterExhaustsMetricStorageTest(bool withView, bool forceFlu
7374

7475
if (forceFlushAfterEachTest)
7576
{
76-
Assert.Empty(exportedItems);
77+
// A collection happened after the first meter was disposed, so its
78+
// storage slot was reclaimed and reused by the second meter's
79+
// instrument, which is therefore exported rather than dropped.
80+
Assert.Single(exportedItems);
7781
}
7882
else
7983
{
@@ -84,7 +88,18 @@ public void TransientMeterExhaustsMetricStorageTest(bool withView, bool forceFlu
8488

8589
var metricInstrumentIgnoredEvents = eventListener.Messages.Where((e) => e.EventId == 33 && (e.Payload?.Count ?? 0) >= 2 && (e.Payload![1] as string) == meterName);
8690

87-
Assert.Single(metricInstrumentIgnoredEvents);
91+
if (forceFlushAfterEachTest)
92+
{
93+
// Storage was reclaimed between the two meters, so no instrument was dropped.
94+
Assert.Empty(metricInstrumentIgnoredEvents);
95+
}
96+
else
97+
{
98+
// No collection happened between the two meters, so the first
99+
// metric's storage had not yet been reclaimed and the second
100+
// instrument was dropped because the stream limit was reached.
101+
Assert.Single(metricInstrumentIgnoredEvents);
102+
}
88103

89104
void RunTest()
90105
{
@@ -103,4 +118,98 @@ void RunTest()
103118
}
104119
}
105120
}
121+
122+
[Fact]
123+
public void TransientMeterMetricStorageIsReusedAcrossManyCollectsTest()
124+
{
125+
using var eventListener = new TestEventListener(OpenTelemetrySdkEventSource.Log);
126+
127+
var meterName = Utils.GetCurrentMethodName();
128+
var exportedItems = new List<Metric>();
129+
130+
const int MaxMetricStreams = 2;
131+
132+
using var meterProvider = Sdk.CreateMeterProviderBuilder()
133+
.SetMaxMetricStreams(MaxMetricStreams)
134+
.AddMeter(meterName)
135+
.AddInMemoryExporter(exportedItems)
136+
.Build() as MeterProviderSdk;
137+
138+
Assert.NotNull(meterProvider);
139+
140+
// Create and dispose many more instruments than the metric stream limit,
141+
// collecting after each one so the deactivated metric's slot is reclaimed.
142+
for (var i = 0; i < MaxMetricStreams * 10; i++)
143+
{
144+
exportedItems.Clear();
145+
146+
using (var meter = new Meter(meterName))
147+
{
148+
var counter = meter.CreateCounter<int>("Counter");
149+
counter.Add(1);
150+
}
151+
152+
meterProvider.ForceFlush();
153+
154+
Assert.Single(exportedItems);
155+
Assert.Equal("Counter", exportedItems[0].Name);
156+
}
157+
158+
// No instrument should ever have been dropped due to exhausted storage.
159+
var metricInstrumentIgnoredEvents = eventListener.Messages.Where((e) => e.EventId == 33 && (e.Payload?.Count ?? 0) >= 2 && (e.Payload![1] as string) == meterName);
160+
161+
Assert.Empty(metricInstrumentIgnoredEvents);
162+
}
163+
164+
[Fact]
165+
public void TransientMetricStreamNameRegistrationsAreReleasedTest()
166+
{
167+
var meterName = Utils.GetCurrentMethodName();
168+
var exportedItems = new List<Metric>();
169+
170+
const int MaxMetricStreams = 2;
171+
const int Iterations = MaxMetricStreams * 10;
172+
173+
using var meterProvider = Sdk.CreateMeterProviderBuilder()
174+
.SetMaxMetricStreams(MaxMetricStreams)
175+
.AddMeter(meterName)
176+
.AddInMemoryExporter(exportedItems)
177+
.Build() as MeterProviderSdk;
178+
179+
Assert.NotNull(meterProvider);
180+
181+
// Create and dispose many distinctly-named instruments than the metric
182+
// stream limit, collecting after each one so the deactivated metric's
183+
// slot (and its stream-name registration) is reclaimed. Distinct names
184+
// are important: a leak in the stream-name bookkeeping would only show
185+
// up when the names differ between iterations.
186+
for (var i = 0; i < Iterations; i++)
187+
{
188+
exportedItems.Clear();
189+
190+
using (var meter = new Meter(meterName))
191+
{
192+
var counter = meter.CreateCounter<int>($"Counter{i}");
193+
counter.Add(1);
194+
}
195+
196+
meterProvider.ForceFlush();
197+
198+
Assert.Single(exportedItems);
199+
Assert.Equal($"Counter{i}", exportedItems[0].Name);
200+
}
201+
202+
// The stream-name registrations must be released as metrics are removed,
203+
// otherwise the reader retains an entry for every distinct name ever seen.
204+
var reader = meterProvider.Reader;
205+
Assert.NotNull(reader);
206+
207+
var metricStreamNamesField = typeof(MetricReader).GetField("metricStreamNames", BindingFlags.Instance | BindingFlags.NonPublic);
208+
Assert.NotNull(metricStreamNamesField);
209+
210+
var metricStreamNames = Assert.IsType<System.Collections.ICollection>(metricStreamNamesField.GetValue(reader), exactMatch: false);
211+
212+
// Every instrument was disposed and collected, so no registration should remain.
213+
Assert.Empty(metricStreamNames);
214+
}
106215
}

0 commit comments

Comments
 (0)