Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user