diff --git a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs index 8c5e8bc55c0..d890a3dcb2a 100644 --- a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs @@ -303,6 +303,7 @@ public sealed class TelemetryLimitOptions public int MaxAttributeCount { get; set; } = 128; public int MaxAttributeLength { get; set; } = int.MaxValue; public int MaxSpanEventCount { get; set; } = int.MaxValue; + public int MaxResourceCount { get; set; } = 10_000; } public sealed class UIOptions diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs index 2e060587d66..07da1018db5 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs @@ -4,7 +4,6 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -467,18 +466,23 @@ public static bool TryGetOrAddScope(Dictionary scopes, Instru // Semantically when InstrumentationScope isn't set, it is equivalent with // an empty instrumentation scope name (unknown). var name = scope?.Name ?? string.Empty; - ref var scopeRef = ref CollectionsMarshal.GetValueRefOrAddDefault(scopes, name, out _); - // Adds to dictionary if not present. - if (scopeRef == null) + if (scopes.TryGetValue(name, out s)) { - scopeRef = (scope != null) - ? new OtlpScope(scope.Name, scope.Version, scope.Attributes.ToKeyValuePairs(context)) - : OtlpScope.Empty; + return true; + } - context.Logger.LogTrace("Added scope '{ScopeName}' to {TelemetryType}.", scopeRef.Name, telemetryType); + if (scopes.Count >= TelemetryRepository.MaxScopeCount) + { + throw new InvalidOperationException($"Scope limit of {TelemetryRepository.MaxScopeCount} reached for {telemetryType}. Scope '{name}' will not be added."); } - s = scopeRef; + s = (scope != null) + ? new OtlpScope(scope.Name, scope.Version, scope.Attributes.ToKeyValuePairs(context)) + : OtlpScope.Empty; + + scopes.Add(name, s); + + context.Logger.LogTrace("Added scope '{ScopeName}' to {TelemetryType}.", s.Name, telemetryType); return true; } catch (Exception ex) diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpInstrument.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpInstrument.cs index 963e1223a91..5a97d1dce0f 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpInstrument.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpInstrument.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using Aspire.Dashboard.Otlp.Model.MetricValues; +using Aspire.Dashboard.Otlp.Storage; using Google.Protobuf.Collections; using OpenTelemetry.Proto.Common.V1; @@ -63,6 +64,11 @@ public DimensionScope FindScope(RepeatedField attributes, ref KeyValue // Need to add dimensions using durable attributes instance after scope is created. if (!Dimensions.TryGetValue(comparableAttributes, out var dimension)) { + if (Dimensions.Count >= TelemetryRepository.MaxDimensionCount) + { + throw new InvalidOperationException($"Dimension limit of {TelemetryRepository.MaxDimensionCount} reached for instrument '{Summary.Name}'."); + } + dimension = CreateDimensionScope(comparableAttributes); Dimensions.Add(dimension.Attributes, dimension); } @@ -78,28 +84,35 @@ private DimensionScope CreateDimensionScope(Memory> var keys = KnownAttributeValues.Keys.Union(durableAttributes.Select(a => a.Key)).Distinct(); foreach (var key in keys) { - ref var values = ref CollectionsMarshal.GetValueRefOrAddDefault(KnownAttributeValues, key, out _); + ref var values = ref CollectionsMarshal.GetValueRefOrAddDefault(KnownAttributeValues, key, out var existed); // Adds to dictionary if not present. if (values == null) { + if (!existed && KnownAttributeValues.Count > TelemetryRepository.MaxKnownAttributeValueCount) + { + // Over limit. Remove the default entry that GetValueRefOrAddDefault added. + KnownAttributeValues.Remove(key); + continue; + } + values = new List(); // If the key is new and there are already dimensions, add an empty value because there are dimensions without this key. if (!isFirst) { - TryAddValue(values, null); + TryAddValue(values, null, TelemetryRepository.MaxKnownAttributeValuesPerKey); } } var currentDimensionValue = OtlpHelpers.GetValue(durableAttributes, key); - TryAddValue(values, currentDimensionValue); + TryAddValue(values, currentDimensionValue, TelemetryRepository.MaxKnownAttributeValuesPerKey); } return dimension; - static void TryAddValue(List values, string? value) + static void TryAddValue(List values, string? value, int maxValues) { - if (!values.Contains(value)) + if (values.Count < maxValues && !values.Contains(value)) { values.Add(value); } diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs index c2c058901e4..9a430663769 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs @@ -4,7 +4,6 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Runtime.InteropServices; using Aspire.Dashboard.Otlp.Storage; using Google.Protobuf.Collections; using OpenTelemetry.Proto.Common.V1; @@ -45,6 +44,7 @@ public class OtlpResource : IOtlpResource public ResourceKey ResourceKey => new ResourceKey(ResourceName, InstanceId); private readonly ReaderWriterLockSlim _metricsLock = new(); + // Bounded by TelemetryRepository.MaxScopeCount. Cleared when metrics are cleared. private readonly Dictionary _meters = new(); private readonly Dictionary _instruments = new(); private readonly ConcurrentDictionary[], OtlpResourceView> _resourceViews = new(ResourceViewKeyComparer.Instance); @@ -70,7 +70,7 @@ public void AddMetrics(AddContext context, RepeatedField scopeMetr { if (!OtlpHelpers.TryGetOrAddScope(_meters, sm.Scope, Context, TelemetryType.Metrics, out var scope)) { - context.FailureCount += sm.Metrics.Count; + context.FailureCount += sm.Metrics.Sum(m => GetMetricDataPointCount(m)); continue; } @@ -86,11 +86,13 @@ public void AddMetrics(AddContext context, RepeatedField scopeMetr } var instrumentKey = new OtlpInstrumentKey(scope.Name, metric.Name); - ref var instrumentRef = ref CollectionsMarshal.GetValueRefOrAddDefault(_instruments, instrumentKey, out _); - if (instrumentRef == null) + if (_instruments.TryGetValue(instrumentKey, out var existingInstrument)) { - // Adds to dictionary if not present. - instrumentRef = new OtlpInstrument + instrument = existingInstrument; + } + else if (_instruments.Count < TelemetryRepository.MaxInstrumentCount) + { + var newInstrument = new OtlpInstrument { Summary = new OtlpInstrumentSummary { @@ -104,10 +106,15 @@ public void AddMetrics(AddContext context, RepeatedField scopeMetr Context = Context }; - Context.Logger.LogTrace("Added metric instrument '{InstrumentName}' for scope '{ScopeName}'.", instrumentRef.Summary.Name, scope.Name); - } + _instruments.Add(instrumentKey, newInstrument); + instrument = newInstrument; - instrument = instrumentRef; + Context.Logger.LogTrace("Added metric instrument '{InstrumentName}' for scope '{ScopeName}'.", instrument.Summary.Name, scope.Name); + } + else + { + throw new InvalidOperationException($"Instrument limit of {TelemetryRepository.MaxInstrumentCount} reached. Instrument '{metric.Name}' will not be added."); + } } catch (Exception ex) { @@ -127,7 +134,7 @@ public void AddMetrics(AddContext context, RepeatedField scopeMetr } } - private static int GetMetricDataPointCount(Metric metric) + internal static int GetMetricDataPointCount(Metric metric) { return metric.DataCase switch { @@ -207,6 +214,7 @@ public void ClearMetrics() try { _instruments.Clear(); + _meters.Clear(); } finally { @@ -296,6 +304,11 @@ internal OtlpResourceView GetView(RepeatedField attributes) return resourceView; } + if (_resourceViews.Count >= TelemetryRepository.MaxResourceViewCount) + { + throw new InvalidOperationException($"Resource view limit of {TelemetryRepository.MaxResourceViewCount} reached."); + } + return _resourceViews.GetOrAdd(view.Properties, view); } diff --git a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs index 0d46579ccd3..bf0eee45d6b 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs @@ -26,6 +26,13 @@ namespace Aspire.Dashboard.Otlp.Storage; public sealed partial class TelemetryRepository : IDisposable { + internal const int MaxResourceViewCount = 10_000; + internal const int MaxInstrumentCount = 10_000; + internal const int MaxScopeCount = 10_000; + internal const int MaxDimensionCount = 10_000; + internal const int MaxKnownAttributeValueCount = 10_000; + internal const int MaxKnownAttributeValuesPerKey = 10_000; + private readonly PauseManager _pauseManager; private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers; private readonly ILogger _logger; @@ -46,15 +53,21 @@ public sealed partial class TelemetryRepository : IDisposable private readonly ConcurrentDictionary _resources = new(); private readonly ReaderWriterLockSlim _logsLock = new(); + // Bounded by MaxScopeCount. Cleared when all logs are cleared. private readonly Dictionary _logScopes = new(); private readonly CircularBuffer _logs; + // Bounded by _resources count * MaxAttributeCount. Cleared per-resource or when all logs are cleared. private readonly HashSet<(OtlpResource Resource, string PropertyKey)> _logPropertyKeys = new(); + // Bounded by _resources count * MaxAttributeCount. Cleared per-resource or when all traces are cleared. private readonly HashSet<(OtlpResource Resource, string PropertyKey)> _tracePropertyKeys = new(); private readonly Dictionary _resourceUnviewedErrorLogs = new(); private readonly ReaderWriterLockSlim _tracesLock = new(); + // Bounded by MaxScopeCount. Cleared when all traces are cleared. private readonly Dictionary _traceScopes = new(); private readonly CircularBuffer _traces; + // Not explicitly capped per add — bounded only by the sum of span links across in-buffer traces. + // Cleaned up on trace eviction and clear, so growth is limited by the circular buffer capacity. private readonly List _spanLinks = new(); private readonly List _peerResolverSubscriptions = new(); internal readonly OtlpContext _otlpContext; @@ -237,6 +250,14 @@ private OtlpResourceView GetOrAddResourceView(Resource resource) return (Resource: resource, IsNew: false); } + // Check resource limit before adding a new resource. + // Note: This is a soft cap. Concurrent callers may both pass this check and slightly exceed the limit + // because _resources is a ConcurrentDictionary and the count check + GetOrAdd are not atomic. + if (_resources.Count >= _otlpContext.Options.MaxResourceCount) + { + throw new InvalidOperationException($"Resource limit of {_otlpContext.Options.MaxResourceCount} reached. Resource '{key}' will not be added."); + } + // Slower get or add path. // This GetOrAdd allocates a closure, so we avoid it if possible. var newResource = false; @@ -323,7 +344,7 @@ public void AddLogs(AddContext context, RepeatedField resourceLogs } catch (Exception ex) { - context.FailureCount += rl.ScopeLogs.Count; + context.FailureCount += rl.ScopeLogs.Sum(s => s.LogRecords.Count); _otlpContext.Logger.LogInformation(ex, "Error adding resource."); continue; } @@ -792,22 +813,41 @@ public void ClearTraces(ResourceKey? resourceKey = null) { // Nothing selected, clear everything. _traces.Clear(); + _traceScopes.Clear(); + _tracePropertyKeys.Clear(); + _spanLinks.Clear(); + + foreach (var resource in _resources.Values) + { + SetResourceHasTraces(resource, false); + } } else { for (var i = _traces.Count - 1; i >= 0; i--) { + var trace = _traces[i]; // Remove trace if any span matches one of the resources. This matches filter behavior. - if (MatchResources(_traces[i], resources)) + if (MatchResources(trace, resources)) { + // Remove span links for the removed trace. + foreach (var span in trace.Spans) + { + foreach (var link in span.Links) + { + _spanLinks.Remove(link); + } + } + _traces.RemoveAt(i); continue; } } - // Update HasTraces flag for cleared resources + // Remove property keys for cleared resources. foreach (var resource in resources) { + _tracePropertyKeys.RemoveWhere(k => k.Resource.ResourceKey == resource.ResourceKey); SetResourceHasTraces(resource, false); } } @@ -836,6 +876,15 @@ public void ClearStructuredLogs(ResourceKey? resourceKey = null) { // Nothing selected, clear everything. _logs.Clear(); + _logScopes.Clear(); + _logPropertyKeys.Clear(); + + foreach (var resource in _resources.Values) + { + SetResourceHasLogs(resource, false); + } + + _resourceUnviewedErrorLogs.Clear(); } else { @@ -848,9 +897,10 @@ public void ClearStructuredLogs(ResourceKey? resourceKey = null) } } - // Update HasLogs flag for cleared resources + // Update HasLogs flag and remove property keys for cleared resources. foreach (var resource in resources) { + _logPropertyKeys.RemoveWhere(k => k.Resource.ResourceKey == resource.ResourceKey); SetResourceHasLogs(resource, false); _resourceUnviewedErrorLogs.Remove(resource.ResourceKey); } @@ -1066,7 +1116,7 @@ public void AddMetrics(AddContext context, RepeatedField resour } catch (Exception ex) { - context.FailureCount += rm.ScopeMetrics.Sum(s => s.Metrics.Count); + context.FailureCount += rm.ScopeMetrics.Sum(sm => sm.Metrics.Sum(OtlpResource.GetMetricDataPointCount)); _otlpContext.Logger.LogInformation(ex, "Error adding resource."); continue; } @@ -1316,9 +1366,17 @@ static bool TryGetTraceById(CircularBuffer traces, ReadOnlyMemory + { + new ResourceSpans + { + Resource = CreateResource(name: $"app{i}"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = { CreateSpan("trace1", $"span{i}", s_testTime, s_testTime.AddMinutes(1)) } + } + } + } + }); + Assert.Equal(0, addContext.FailureCount); + } + + Assert.Equal(3, repository.GetResources().Count); + + // Adding a 4th resource should fail. + var failContext = new AddContext(); + repository.AddTraces(failContext, new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(name: "app-over-limit"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = { CreateSpan("trace2", "spanX", s_testTime, s_testTime.AddMinutes(1)) } + } + } + } + }); + + Assert.Equal(1, failContext.FailureCount); + Assert.Equal(0, failContext.SuccessCount); + Assert.Equal(3, repository.GetResources().Count); + } + + [Fact] + public void AddTraces_ExistingResourceAfterLimitReached_Succeeds() + { + var repository = CreateRepository(maxResourceCount: 2); + + // Add 2 resources to fill up the limit. + for (var i = 0; i < 2; i++) + { + var addContext = new AddContext(); + repository.AddTraces(addContext, new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(name: $"app{i}"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = { CreateSpan("trace1", $"span{i}", s_testTime, s_testTime.AddMinutes(1)) } + } + } + } + }); + Assert.Equal(0, addContext.FailureCount); + } + + // Adding data for an existing resource should still succeed. + var successContext = new AddContext(); + repository.AddTraces(successContext, new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(name: "app0"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = { CreateSpan("trace2", "spanNew", s_testTime, s_testTime.AddMinutes(2)) } + } + } + } + }); + + Assert.Equal(0, successContext.FailureCount); + Assert.Equal(1, successContext.SuccessCount); + } + + [Fact] + public void AddMetrics_ExceedsInstrumentLimit_ReportsFailure() + { + var repository = CreateRepository(); + + // Fill instruments up to the limit. + var metrics = new RepeatedField(); + for (var i = 0; i < TelemetryRepository.MaxInstrumentCount; i++) + { + metrics.Add(CreateSumMetric(metricName: $"metric{i}", startTime: s_testTime.AddMinutes(1))); + } + + var addContext = new AddContext(); + repository.AddMetrics(addContext, new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = { metrics } + } + } + } + }); + + Assert.Equal(0, addContext.FailureCount); + + var resources = repository.GetResources(); + var instruments = repository.GetInstrumentsSummaries(resources[0].ResourceKey); + Assert.Equal(TelemetryRepository.MaxInstrumentCount, instruments.Count); + + // Adding one more instrument should fail. + var failContext = new AddContext(); + repository.AddMetrics(failContext, new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = { CreateSumMetric(metricName: "over-limit-metric", startTime: s_testTime.AddMinutes(2)) } + } + } + } + }); + + Assert.Equal(1, failContext.FailureCount); + Assert.Equal(0, failContext.SuccessCount); + + instruments = repository.GetInstrumentsSummaries(resources[0].ResourceKey); + Assert.Equal(TelemetryRepository.MaxInstrumentCount, instruments.Count); + } + + [Fact] + public void AddLogs_ExceedsResourceLimit_FailureCountIsLogRecordCount() + { + var repository = CreateRepository(maxResourceCount: 1); + + // Fill the single resource slot. + var setupContext = new AddContext(); + repository.AddLogs(setupContext, new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(name: "app0"), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("logger"), + LogRecords = { CreateLogRecord() } + } + } + } + }); + Assert.Equal(0, setupContext.FailureCount); + + // Attempt to add logs for a new resource with multiple scopes and records. + // FailureCount must equal total log records, not number of scopes. + var failContext = new AddContext(); + repository.AddLogs(failContext, new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(name: "app-over-limit"), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("loggerA"), + LogRecords = + { + CreateLogRecord(message: "a1"), + CreateLogRecord(message: "a2"), + CreateLogRecord(message: "a3") + } + }, + new ScopeLogs + { + Scope = CreateScope("loggerB"), + LogRecords = + { + CreateLogRecord(message: "b1"), + CreateLogRecord(message: "b2") + } + } + } + } + }); + + Assert.Equal(5, failContext.FailureCount); + Assert.Equal(0, failContext.SuccessCount); + } + + [Fact] + public void AddMetrics_ExceedsResourceLimit_FailureCountIsDataPointCount() + { + var repository = CreateRepository(maxResourceCount: 1); + + // Fill the single resource slot. + var setupContext = new AddContext(); + repository.AddMetrics(setupContext, new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(name: "app0"), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "meter"), + Metrics = { CreateSumMetric(metricName: "m0", startTime: s_testTime.AddMinutes(1)) } + } + } + } + }); + Assert.Equal(0, setupContext.FailureCount); + + // Attempt to add metrics for a new resource with multiple scopes and metrics. + // FailureCount must equal total data points, not number of metrics. + var failContext = new AddContext(); + repository.AddMetrics(failContext, new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(name: "app-over-limit"), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "meterA"), + Metrics = + { + CreateSumMetric(metricName: "m1", startTime: s_testTime.AddMinutes(1)), + CreateSumMetric(metricName: "m2", startTime: s_testTime.AddMinutes(1)), + CreateSumMetric(metricName: "m3", startTime: s_testTime.AddMinutes(1)) + } + }, + new ScopeMetrics + { + Scope = CreateScope(name: "meterB"), + Metrics = + { + CreateSumMetric(metricName: "m4", startTime: s_testTime.AddMinutes(1)), + CreateSumMetric(metricName: "m5", startTime: s_testTime.AddMinutes(1)) + } + } + } + } + }); + + // Each CreateSumMetric produces 1 data point, so 5 metrics = 5 data points. + Assert.Equal(5, failContext.FailureCount); + Assert.Equal(0, failContext.SuccessCount); + } + + [Fact] + public void AddTraces_ExceedsResourceLimit_FailureCountIsSpanCount() + { + var repository = CreateRepository(maxResourceCount: 1); + + // Fill the single resource slot. + var setupContext = new AddContext(); + repository.AddTraces(setupContext, new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(name: "app0"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = { CreateSpan("trace1", "span0", s_testTime, s_testTime.AddMinutes(1)) } + } + } + } + }); + Assert.Equal(0, setupContext.FailureCount); + + // Attempt to add traces for a new resource with multiple scopes and spans. + // FailureCount must equal total spans, not number of scopes. + var failContext = new AddContext(); + repository.AddTraces(failContext, new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(name: "app-over-limit"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan("trace2", "spanA1", s_testTime, s_testTime.AddMinutes(1)), + CreateSpan("trace2", "spanA2", s_testTime, s_testTime.AddMinutes(1)) + } + }, + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan("trace3", "spanB1", s_testTime, s_testTime.AddMinutes(1)) + } + } + } + } + }); + + Assert.Equal(3, failContext.FailureCount); + Assert.Equal(0, failContext.SuccessCount); + } + + [Fact] + public void AddLogs_ExceedsScopeLimit_ReportsFailure() + { + var repository = CreateRepository(); + + // Fill scopes up to the limit. + var scopeLogs = new RepeatedField(); + var rl = new ResourceLogs { Resource = CreateResource() }; + for (var i = 0; i < TelemetryRepository.MaxScopeCount; i++) + { + rl.ScopeLogs.Add(new ScopeLogs + { + Scope = CreateScope(name: $"logger{i}"), + LogRecords = { CreateLogRecord() } + }); + } + scopeLogs.Add(rl); + + var addContext = new AddContext(); + repository.AddLogs(addContext, scopeLogs); + Assert.Equal(0, addContext.FailureCount); + + // Adding one more scope should fail. + var failContext = new AddContext(); + repository.AddLogs(failContext, new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope(name: "over-limit-logger"), + LogRecords = + { + CreateLogRecord(message: "a"), + CreateLogRecord(message: "b"), + CreateLogRecord(message: "c") + } + } + } + } + }); + + Assert.Equal(3, failContext.FailureCount); + Assert.Equal(0, failContext.SuccessCount); + } + + [Fact] + public void AddTraces_ExceedsScopeLimit_ReportsFailure() + { + var repository = CreateRepository(); + + // Fill scopes up to the limit. + var rs = new ResourceSpans { Resource = CreateResource() }; + for (var i = 0; i < TelemetryRepository.MaxScopeCount; i++) + { + rs.ScopeSpans.Add(new ScopeSpans + { + Scope = CreateScope(name: $"tracer{i}"), + Spans = { CreateSpan($"trace{i}", $"span{i}", s_testTime, s_testTime.AddMinutes(1)) } + }); + } + + var addContext = new AddContext(); + repository.AddTraces(addContext, new RepeatedField { rs }); + Assert.Equal(0, addContext.FailureCount); + + // Adding one more scope should fail. + var failContext = new AddContext(); + repository.AddTraces(failContext, new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(name: "over-limit-tracer"), + Spans = + { + CreateSpan("traceX", "spanX1", s_testTime, s_testTime.AddMinutes(1)), + CreateSpan("traceX", "spanX2", s_testTime, s_testTime.AddMinutes(2)) + } + } + } + } + }); + + Assert.Equal(2, failContext.FailureCount); + Assert.Equal(0, failContext.SuccessCount); + } + + [Fact] + public void AddMetrics_ExceedsScopeLimit_ReportsFailure() + { + var repository = CreateRepository(); + + // Fill scopes up to the limit. + var rm = new ResourceMetrics { Resource = CreateResource() }; + for (var i = 0; i < TelemetryRepository.MaxScopeCount; i++) + { + rm.ScopeMetrics.Add(new ScopeMetrics + { + Scope = CreateScope(name: $"meter{i}"), + Metrics = { CreateSumMetric(metricName: $"metric{i}", startTime: s_testTime.AddMinutes(1)) } + }); + } + + var addContext = new AddContext(); + repository.AddMetrics(addContext, new RepeatedField { rm }); + Assert.Equal(0, addContext.FailureCount); + + // Adding one more scope should fail. Each metric has 1 data point. + var failContext = new AddContext(); + repository.AddMetrics(failContext, new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "over-limit-meter"), + Metrics = + { + CreateSumMetric(metricName: "m1", startTime: s_testTime.AddMinutes(1)), + CreateSumMetric(metricName: "m2", startTime: s_testTime.AddMinutes(1)) + } + } + } + } + }); + + // 2 metrics × 1 data point each = 2 rejected data points. + Assert.Equal(2, failContext.FailureCount); + Assert.Equal(0, failContext.SuccessCount); + } +} diff --git a/tests/Shared/Telemetry/TelemetryTestHelpers.cs b/tests/Shared/Telemetry/TelemetryTestHelpers.cs index 16dcad85bd6..0b559ccb215 100644 --- a/tests/Shared/Telemetry/TelemetryTestHelpers.cs +++ b/tests/Shared/Telemetry/TelemetryTestHelpers.cs @@ -238,6 +238,7 @@ public static TelemetryRepository CreateRepository( int? maxSpanEventCount = null, int? maxTraceCount = null, int? maxLogCount = null, + int? maxResourceCount = null, TimeSpan? subscriptionMinExecuteInterval = null, ILoggerFactory? loggerFactory = null, PauseManager? pauseManager = null, @@ -268,6 +269,10 @@ public static TelemetryRepository CreateRepository( { options.MaxLogCount = maxLogCount.Value; } + if (maxResourceCount != null) + { + options.MaxResourceCount = maxResourceCount.Value; + } var repository = new TelemetryRepository( loggerFactory ?? NullLoggerFactory.Instance,