// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Runtime.ExceptionServices;
using Aspire.Dashboard.Telemetry;
using Aspire.Dashboard.Utils;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
using Xunit;
namespace Aspire.Dashboard.Tests.Telemetry;
public class TelemetryErrorRecorderTests
{
[Fact]
public void RecordError_Exception_RecordsOriginalException()
{
using var fixture = new DashboardTelemetryFixture();
var recorder = CreateRecorder(fixture);
var exception = CreateException(new InvalidOperationException("Test error"), "original stack");
recorder.RecordError("Test message", exception);
AssertError(fixture, Assert.Single(ReadErrors(fixture)), exception);
}
[Fact]
public void RecordError_AggregateException_RecordsDistinctLeafExceptions()
{
using var fixture = new DashboardTelemetryFixture();
var recorder = CreateRecorder(fixture);
var exception = CreateException(new InvalidOperationException("Test error"), "original stack");
var duplicate = CreateException(new InvalidOperationException("Test error"), "original stack");
var differentType = CreateException(new ArgumentException("Test error"), "original stack");
var differentMessage = CreateException(new InvalidOperationException("Different error"), "original stack");
var differentStack = CreateException(new InvalidOperationException("Test error"), "different stack");
var aggregate = new AggregateException(
"Exceptions were encountered while disposing components.",
exception,
duplicate,
differentType,
differentMessage,
differentStack,
new AggregateException(duplicate, new AggregateException(exception)));
Assert.Null(aggregate.StackTrace);
recorder.RecordError("Test message", aggregate);
Assert.Collection(ReadErrors(fixture),
log => AssertError(fixture, log, exception),
log => AssertError(fixture, log, differentType),
log => AssertError(fixture, log, differentMessage),
log => AssertError(fixture, log, differentStack));
}
[Fact]
public void RecordError_AggregateExceptionWithoutStacks_DeduplicatesExceptions()
{
using var fixture = new DashboardTelemetryFixture();
var recorder = CreateRecorder(fixture);
var exception = new InvalidOperationException("Test error");
var aggregate = new AggregateException(exception, new InvalidOperationException("Test error"));
recorder.RecordError("Test message", aggregate);
AssertError(fixture, Assert.Single(ReadErrors(fixture)), exception);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void RecordError_EmptyAggregateException_RecordsOriginalException(bool nested)
{
using var fixture = new DashboardTelemetryFixture();
var recorder = CreateRecorder(fixture);
var exception = nested ? new AggregateException(new AggregateException()) : new AggregateException();
recorder.RecordError("Test message", exception);
AssertError(fixture, Assert.Single(ReadErrors(fixture)), exception);
}
[Fact]
public void RecordError_RepeatedCalls_RecordsEachOccurrence()
{
using var fixture = new DashboardTelemetryFixture();
var recorder = CreateRecorder(fixture);
var exception = CreateException(new InvalidOperationException("Test error"), "original stack");
var aggregate = new AggregateException(exception, exception);
recorder.RecordError("Test message", aggregate);
recorder.RecordError("Test message", aggregate);
Assert.Collection(ReadErrors(fixture),
log => AssertError(fixture, log, exception),
log => AssertError(fixture, log, exception));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void RecordError_WriteToLogging_LogsOriginalExceptionOnce(bool writeToLogging)
{
using var fixture = new DashboardTelemetryFixture();
var sink = new TestSink();
using var loggerFactory = new TestLoggerFactory(sink, enabled: true);
var logger = new TestLogger<TelemetryErrorRecorder>(loggerFactory);
var recorder = new TelemetryErrorRecorder(fixture.Telemetry, logger);
var exception = new InvalidOperationException("Test error");
var aggregate = new AggregateException(exception, exception);
recorder.RecordError("Test message", aggregate, writeToLogging);
var logs = sink.Writes;
if (writeToLogging)
{
var log = Assert.Single(logs);
Assert.Same(aggregate, log.Exception);
Assert.Equal("Test message", log.Message);
}
else
{
Assert.Empty(logs);
}
Assert.Empty(fixture.LocalLogSink.Writes);
AssertError(fixture, Assert.Single(ReadErrors(fixture)), exception);
}
[Fact]
public void RecordError_TelemetryDisabled_DoesNotExportLogs()
{
using var fixture = new DashboardTelemetryFixture(reportedTelemetryEnabled: false);
var recorder = CreateRecorder(fixture);
recorder.RecordError("Test message", new AggregateException(new InvalidOperationException("Test error")));
Assert.Empty(ReadErrors(fixture));
Assert.False(fixture.ActivityChannel.Reader.TryPeek(out _));
}
private static TelemetryErrorRecorder CreateRecorder(DashboardTelemetryFixture fixture) =>
new(fixture.Telemetry, NullLogger<TelemetryErrorRecorder>.Instance);
private static Exception CreateException(Exception exception, string stackTrace)
{
ExceptionDispatchInfo.SetRemoteStackTrace(exception, stackTrace);
return exception;
}
internal static void AssertError(DashboardTelemetryFixture fixture, TestDashboardTelemetryLog log, Exception exception)
{
Assert.Equal(TelemetryEventKeys.Error, log.Message);
Assert.Equal(LogLevel.Information, log.Level);
Assert.Equal(DashboardTelemetryService.EventLogCategoryName, log.CategoryName);
fixture.AssertAttributes(log.Attributes,
new(TelemetryPropertyKeys.ExceptionType, exception.GetType().FullName),
new("microsoft.operation_name", TelemetryEventKeys.Error),
new(TelemetryPropertyKeys.ExceptionRuntimeVersion, VersionHelpers.RuntimeVersion?.ToString() ?? string.Empty),
new("{OriginalFormat}", TelemetryEventKeys.Error));
}
private static List<TestDashboardTelemetryLog> ReadErrors(DashboardTelemetryFixture fixture)
{
Assert.False(fixture.ActivityChannel.Reader.TryPeek(out _));
var logs = new List<TestDashboardTelemetryLog>();
while (fixture.LogChannel.Reader.TryRead(out var log))
{
logs.Add(log);
}
return logs;
}
}