using Omika.Core.Observability; namespace Omika.Tests.Observability; /// Tests de la capture centralisée d'exceptions (Phase 6). [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); } /// /// Événement de diagnostic produit par CE test. /// /// La façade 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 Assert.Single sur /// toute la collection devient intermittente. On isole donc l'événement attendu par /// son composant et/ou son code d'erreur. /// private static DiagnosticEvent Evenement( CapturingTelemetry telemetry, string? component = null, string? errorCode = null) => Assert.Single( telemetry.Events.OfType(), e => (component is null || e.Component == component) && (errorCode is null || e.ErrorCode == errorCode)); /// /// 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. /// private static Exception Lever(Exception exception) { try { throw exception; } catch (Exception capturee) { return capturee; } } /// /// 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. /// 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 Events { get; } = []; public void Track(TelemetryEvent telemetryEvent) => Events.Add(telemetryEvent); public void Log(DiagnosticEvent diagnosticEvent) => Events.Add(diagnosticEvent); } }