408 lines
16 KiB
C#
408 lines
16 KiB
C#
using Omika.Core.Observability;
|
|
|
|
namespace Omika.Tests.Observability;
|
|
|
|
/// <summary>Tests de la capture centralisée d'exceptions (Phase 6).</summary>
|
|
[Collection("TelemetrieStatique")]
|
|
public class ExceptionTelemetryTests : IDisposable
|
|
{
|
|
private readonly string _dir = Path.Combine(Path.GetTempPath(), "omika-exc-" + Guid.NewGuid().ToString("N"));
|
|
|
|
public ExceptionTelemetryTests()
|
|
{
|
|
Directory.CreateDirectory(_dir);
|
|
}
|
|
|
|
[Fact]
|
|
public void LogException_EmitsErrorEventWithExceptionDetail()
|
|
{
|
|
var telemetry = new CapturingTelemetry();
|
|
ExceptionTelemetry.Initialize(telemetry, null);
|
|
|
|
ExceptionTelemetry.LogException(
|
|
new InvalidOperationException("boom"),
|
|
TelemetryEventCodes.MobileHandledException,
|
|
component: "SyncEngine");
|
|
|
|
OmikaEvent evt = Evenement(telemetry, "SyncEngine");
|
|
Assert.Equal(TelemetryLevel.Error, evt.Level);
|
|
Assert.Equal("InvalidOperationException", evt.ErrorCode);
|
|
Assert.Equal("System.InvalidOperationException", evt.ExceptionType);
|
|
Assert.Equal("boom", evt.Message);
|
|
Assert.False(evt.Success);
|
|
Assert.Equal("SyncEngine", evt.Component);
|
|
}
|
|
|
|
[Fact]
|
|
public void LogException_TruncatesLongFields()
|
|
{
|
|
var telemetry = new CapturingTelemetry();
|
|
ExceptionTelemetry.Initialize(telemetry, null);
|
|
|
|
ExceptionTelemetry.LogException(
|
|
new Exception(new string('m', 500)) { Data = { } },
|
|
TelemetryEventCodes.MobileHandledException);
|
|
|
|
OmikaEvent evt = Evenement(telemetry, errorCode: "Exception");
|
|
Assert.NotNull(evt.Message);
|
|
Assert.True(evt.Message!.Length <= ExceptionTelemetry.MaxMessageLength);
|
|
Assert.NotNull(evt.ExceptionType);
|
|
Assert.True(evt.ExceptionType!.Length <= ExceptionTelemetry.MaxExceptionTypeLength);
|
|
}
|
|
|
|
[Fact]
|
|
public void Capture_Fatal_WritesCrashFileAndEmitsCritical()
|
|
{
|
|
var telemetry = new CapturingTelemetry();
|
|
ExceptionTelemetry.Initialize(telemetry, _dir);
|
|
|
|
ExceptionTelemetry.Capture(new InvalidOperationException("crash"), isFatal: true);
|
|
|
|
OmikaEvent evt = Evenement(telemetry, "UnhandledException");
|
|
Assert.Equal(TelemetryLevel.Critical, evt.Level);
|
|
Assert.Equal(TelemetryEventCodes.MobileUnhandledException.Value, evt.EventCode.Value);
|
|
|
|
string[] crashFiles = Directory.EnumerateFiles(_dir, "omika-crash-*.log").ToArray();
|
|
Assert.Single(crashFiles);
|
|
Assert.Contains("crash", File.ReadAllText(crashFiles[0]));
|
|
}
|
|
|
|
[Fact]
|
|
public void Capture_NonFatal_EmitsError_WithoutCrashFile()
|
|
{
|
|
var telemetry = new CapturingTelemetry();
|
|
ExceptionTelemetry.Initialize(telemetry, _dir);
|
|
|
|
ExceptionTelemetry.Capture(new InvalidOperationException("bg"), isFatal: false);
|
|
|
|
Assert.Equal("bg", Evenement(telemetry, "TaskScheduler.UnobservedTask").Message);
|
|
Assert.Empty(Directory.EnumerateFiles(_dir, "omika-crash-*.log"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Capture_WithoutInitializedTelemetry_DoesNotThrow()
|
|
{
|
|
ExceptionTelemetry.Initialize(null, _dir);
|
|
|
|
ExceptionTelemetry.Capture(new InvalidOperationException("x"), isFatal: true);
|
|
}
|
|
|
|
[Fact]
|
|
public void LogException_SignaleLaTroncatureParUnMarqueur()
|
|
{
|
|
var telemetry = new CapturingTelemetry();
|
|
ExceptionTelemetry.Initialize(telemetry, null);
|
|
|
|
ExceptionTelemetry.LogException(
|
|
new Exception(new string('m', 500)),
|
|
TelemetryEventCodes.MobileHandledException,
|
|
component: "TestTelemetrie");
|
|
|
|
OmikaEvent evt = Evenement(telemetry, "TestTelemetrie");
|
|
Assert.NotNull(evt.Message);
|
|
Assert.EndsWith("…[tronqué]", evt.Message, StringComparison.Ordinal);
|
|
Assert.True(evt.Message!.Length <= ExceptionTelemetry.MaxMessageLength);
|
|
}
|
|
|
|
// ===== Dépliage des agrégations =====
|
|
// Reproduit le payload réel d'une tâche non observée : le runtime relance
|
|
// l'exception du finaliseur sous forme d'AggregateException (message d'enveloppe,
|
|
// pile nulle). Sans dépliage, le diagnostic n'indique ni le type, ni le message,
|
|
// ni l'origine de la cause réelle.
|
|
|
|
[Fact]
|
|
public void Capture_TacheNonObservee_ExposeLaCauseInterneEtSaPile()
|
|
{
|
|
var telemetry = new CapturingTelemetry();
|
|
ExceptionTelemetry.Initialize(telemetry, _dir);
|
|
|
|
// Cause RÉELLE du runtime : toucher le jeton d'une source libérée lève
|
|
// exactement l'exception du payload de production, message compris.
|
|
var cts = new CancellationTokenSource();
|
|
cts.Dispose();
|
|
Exception cause = LeverReelle(() => _ = cts.Token);
|
|
Assert.Equal("The CancellationTokenSource has been disposed.", cause.Message);
|
|
|
|
var aggregate = new AggregateException(
|
|
"A Task's exception(s) were not observed either by Waiting on the Task or accessing its "
|
|
+ "Exception property. As a result, the unobserved exception was rethrown by the finalizer thread.",
|
|
cause);
|
|
|
|
ExceptionTelemetry.Capture(aggregate, isFatal: false);
|
|
|
|
OmikaEvent evt = Evenement(telemetry, "TaskScheduler.UnobservedTask", "ObjectDisposedException");
|
|
Assert.Equal(TelemetryEventCodes.MobileHandledException.Value, evt.EventCode.Value);
|
|
Assert.Equal(TelemetryLevel.Error, evt.Level);
|
|
Assert.Equal("TaskScheduler.UnobservedTask", evt.Component);
|
|
Assert.Equal("ObjectDisposedException", evt.ErrorCode);
|
|
Assert.Equal("System.ObjectDisposedException", evt.ExceptionType);
|
|
Assert.Equal("The CancellationTokenSource has been disposed.", evt.Message);
|
|
Assert.False(string.IsNullOrWhiteSpace(evt.StackTrace));
|
|
Assert.Contains(nameof(LeverReelle), evt.StackTrace!);
|
|
Assert.DoesNotContain("unobserved exception was rethrown", evt.Message!);
|
|
}
|
|
|
|
[Fact]
|
|
public void Capture_AgregatSansPile_PrefereLaCausePorteuseDUnePile()
|
|
{
|
|
var telemetry = new CapturingTelemetry();
|
|
ExceptionTelemetry.Initialize(telemetry, null);
|
|
|
|
// La première interne n'a jamais été levée (aucune pile) : c'est la seconde,
|
|
// porteuse d'une pile, qui localise l'origine.
|
|
var aggregate = new AggregateException(
|
|
new InvalidOperationException("sans pile"),
|
|
Lever(new IOException("avec pile")));
|
|
|
|
ExceptionTelemetry.Capture(aggregate, isFatal: false);
|
|
|
|
OmikaEvent evt = Evenement(telemetry, "TaskScheduler.UnobservedTask", "IOException");
|
|
Assert.Equal("System.IO.IOException", evt.ExceptionType);
|
|
Assert.StartsWith("avec pile", evt.Message);
|
|
Assert.Contains("sans pile", evt.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void Capture_AgregatsImbriques_ResoutLaCauseRacine()
|
|
{
|
|
var telemetry = new CapturingTelemetry();
|
|
ExceptionTelemetry.Initialize(telemetry, null);
|
|
|
|
var aggregate = new AggregateException(
|
|
new AggregateException(Lever(new ObjectDisposedException(null, "cause racine"))));
|
|
|
|
ExceptionTelemetry.Capture(aggregate, isFatal: false);
|
|
|
|
OmikaEvent evt = Evenement(telemetry, "TaskScheduler.UnobservedTask", "ObjectDisposedException");
|
|
Assert.Equal("System.ObjectDisposedException", evt.ExceptionType);
|
|
Assert.Equal("cause racine", evt.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void Capture_AgregatSansCauseInterne_ConserveLEnveloppe()
|
|
{
|
|
var telemetry = new CapturingTelemetry();
|
|
ExceptionTelemetry.Initialize(telemetry, null);
|
|
|
|
ExceptionTelemetry.Capture(new AggregateException("agrégat vide"), isFatal: false);
|
|
|
|
OmikaEvent evt = Evenement(telemetry, "TaskScheduler.UnobservedTask", "AggregateException");
|
|
Assert.Equal("AggregateException", evt.ErrorCode);
|
|
Assert.Equal("System.AggregateException", evt.ExceptionType);
|
|
Assert.Equal("agrégat vide", evt.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void Capture_AgregatMultiCauses_MessageBorneEtCiteLesAutresCauses()
|
|
{
|
|
var telemetry = new CapturingTelemetry();
|
|
ExceptionTelemetry.Initialize(telemetry, null);
|
|
|
|
var aggregate = new AggregateException(
|
|
Enumerable.Range(0, 12).Select(i => Lever(new TimeoutException(new string('t', 200) + i))));
|
|
|
|
ExceptionTelemetry.Capture(aggregate, isFatal: false);
|
|
|
|
OmikaEvent evt = Evenement(telemetry, "TaskScheduler.UnobservedTask", "TimeoutException");
|
|
Assert.Equal("System.TimeoutException", evt.ExceptionType);
|
|
Assert.True(evt.Message!.Length <= ExceptionTelemetry.MaxMessageLength);
|
|
Assert.Contains("+11 autre(s) cause(s) agrégée(s)", evt.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void Capture_Fatal_Agregat_EcritLaCauseDansLeFichierDeCrash()
|
|
{
|
|
var telemetry = new CapturingTelemetry();
|
|
ExceptionTelemetry.Initialize(telemetry, _dir);
|
|
|
|
ExceptionTelemetry.Capture(
|
|
new AggregateException(Lever(new ObjectDisposedException("crash agrégé"))),
|
|
isFatal: true);
|
|
|
|
Assert.Equal(TelemetryLevel.Critical, Evenement(telemetry, "UnhandledException").Level);
|
|
|
|
string[] crashFiles = Directory.EnumerateFiles(_dir, "omika-crash-*.log").ToArray();
|
|
Assert.Single(crashFiles);
|
|
string contenu = File.ReadAllText(crashFiles[0]);
|
|
Assert.Contains("System.ObjectDisposedException", contenu);
|
|
Assert.Contains("crash agrégé", contenu);
|
|
Assert.DoesNotContain("AggregateException", contenu);
|
|
}
|
|
|
|
// ===== Chaîne des causes internes =====
|
|
// Reproduit le payload réel « Connection failure » : le message du handler HTTP
|
|
// Android ne dit pas s'il s'agit d'un échec DNS, d'une connexion refusée, d'une
|
|
// route absente ou d'un échec TLS. La cause interne, elle, le dit.
|
|
|
|
[Fact]
|
|
public void Capture_ExceptionAvecCauseInterne_CiteLaCauseInterne()
|
|
{
|
|
var telemetry = new CapturingTelemetry();
|
|
ExceptionTelemetry.Initialize(telemetry, null);
|
|
|
|
ExceptionTelemetry.LogException(
|
|
new HttpRequestException(
|
|
"Connection failure",
|
|
new IOException("failed to connect to /10.0.0.12:443")),
|
|
TelemetryEventCodes.MobileHandledException,
|
|
component: "TestTelemetrie");
|
|
|
|
OmikaEvent evt = Evenement(telemetry, "TestTelemetrie");
|
|
// Le type transmis reste celui de l'exception attrapée : la cause interne
|
|
// enrichit le message, elle ne remplace pas l'identité de l'erreur.
|
|
Assert.Equal("HttpRequestException", evt.ErrorCode);
|
|
Assert.Equal("System.Net.Http.HttpRequestException", evt.ExceptionType);
|
|
Assert.NotNull(evt.Message);
|
|
Assert.StartsWith("Connection failure", evt.Message);
|
|
Assert.Contains(
|
|
"cause interne : IOException: failed to connect to /10.0.0.12:443",
|
|
evt.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void Capture_CausesInternesImbriquees_CiteLaChaineDansLOrdre()
|
|
{
|
|
var telemetry = new CapturingTelemetry();
|
|
ExceptionTelemetry.Initialize(telemetry, null);
|
|
|
|
ExceptionTelemetry.LogException(
|
|
new InvalidOperationException(
|
|
"haut",
|
|
new IOException("milieu", new TimeoutException("bas"))),
|
|
TelemetryEventCodes.MobileHandledException,
|
|
component: "TestTelemetrie");
|
|
|
|
string message = Evenement(telemetry, "TestTelemetrie").Message!;
|
|
int haut = message.IndexOf("haut", StringComparison.Ordinal);
|
|
int milieu = message.IndexOf("milieu", StringComparison.Ordinal);
|
|
int bas = message.IndexOf("bas", StringComparison.Ordinal);
|
|
|
|
Assert.True(haut >= 0, message);
|
|
Assert.True(milieu > haut, message);
|
|
Assert.True(bas > milieu, message);
|
|
Assert.Contains("IOException: milieu", message);
|
|
Assert.Contains("TimeoutException: bas", message);
|
|
}
|
|
|
|
[Fact]
|
|
public void Capture_ChaineDeCausesTropProfonde_EstBornee()
|
|
{
|
|
var telemetry = new CapturingTelemetry();
|
|
ExceptionTelemetry.Initialize(telemetry, null);
|
|
|
|
Exception profonde = new Exception("cause-10");
|
|
for (int i = 9; i >= 1; i--)
|
|
{
|
|
profonde = new InvalidOperationException($"cause-{i:00}", profonde);
|
|
}
|
|
|
|
ExceptionTelemetry.LogException(
|
|
profonde,
|
|
TelemetryEventCodes.MobileHandledException,
|
|
component: "TestTelemetrie");
|
|
|
|
string message = Evenement(telemetry, "TestTelemetrie").Message!;
|
|
Assert.Contains("cause-01", message);
|
|
Assert.DoesNotContain("cause-07", message);
|
|
Assert.Contains("…", message);
|
|
Assert.True(message.Length <= ExceptionTelemetry.MaxMessageLength, message);
|
|
}
|
|
|
|
[Fact]
|
|
public void Capture_Agregat_CiteLaCauseInterneEtLesAutresCauses()
|
|
{
|
|
var telemetry = new CapturingTelemetry();
|
|
ExceptionTelemetry.Initialize(telemetry, null);
|
|
|
|
ExceptionTelemetry.LogException(
|
|
new AggregateException(
|
|
Lever(new HttpRequestException("échec réseau", new IOException("connexion refusée"))),
|
|
Lever(new InvalidOperationException("autre cause"))),
|
|
TelemetryEventCodes.MobileHandledException,
|
|
component: "TestTelemetrie");
|
|
|
|
string message = Evenement(telemetry, "TestTelemetrie").Message!;
|
|
Assert.StartsWith("échec réseau", message);
|
|
Assert.Contains("cause interne : IOException: connexion refusée", message);
|
|
Assert.Contains("+1 autre(s) cause(s) agrégée(s)", message);
|
|
Assert.Contains("InvalidOperationException: autre cause", message);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Événement de diagnostic produit par CE test.
|
|
///
|
|
/// La façade <see cref="ExceptionTelemetry"/> est un état statique global, partagé
|
|
/// avec les classes de tests exécutées en parallèle : une capture peut donc recevoir
|
|
/// les événements d'un autre test (usage ou erreur) et un <c>Assert.Single</c> sur
|
|
/// toute la collection devient intermittente. On isole donc l'événement attendu par
|
|
/// son composant et/ou son code d'erreur.
|
|
/// </summary>
|
|
private static DiagnosticEvent Evenement(
|
|
CapturingTelemetry telemetry,
|
|
string? component = null,
|
|
string? errorCode = null) =>
|
|
Assert.Single(
|
|
telemetry.Events.OfType<DiagnosticEvent>(),
|
|
e => (component is null || e.Component == component)
|
|
&& (errorCode is null || e.ErrorCode == errorCode));
|
|
|
|
/// <summary>
|
|
/// Retourne l'exception réellement levée puis attrapée : une exception seulement
|
|
/// construite n'a pas de pile d'appels, or c'est la pile qui localise l'origine.
|
|
/// </summary>
|
|
private static Exception Lever(Exception exception)
|
|
{
|
|
try
|
|
{
|
|
throw exception;
|
|
}
|
|
catch (Exception capturee)
|
|
{
|
|
return capturee;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exécute une action qui doit lever : retourne l'exception réelle du runtime
|
|
/// (avec son message et sa pile d'origine), pour tester sur la cause authentique.
|
|
/// </summary>
|
|
private static Exception LeverReelle(Action action)
|
|
{
|
|
try
|
|
{
|
|
action();
|
|
}
|
|
catch (Exception capturee)
|
|
{
|
|
return capturee;
|
|
}
|
|
|
|
throw new InvalidOperationException("Aucune exception levée : le scénario de test est invalide.");
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
ExceptionTelemetry.Initialize(null, null);
|
|
try
|
|
{
|
|
if (Directory.Exists(_dir))
|
|
{
|
|
Directory.Delete(_dir, recursive: true);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort.
|
|
}
|
|
}
|
|
|
|
private sealed class CapturingTelemetry : IOmikaTelemetry
|
|
{
|
|
public List<OmikaEvent> Events { get; } = [];
|
|
|
|
public void Track(TelemetryEvent telemetryEvent) => Events.Add(telemetryEvent);
|
|
|
|
public void Log(DiagnosticEvent diagnosticEvent) => Events.Add(diagnosticEvent);
|
|
}
|
|
}
|