using Omika.Core.Observability; using Omika.Diagnostics.Models; using Omika.Diagnostics.Queries; using Omika.Diagnostics.Repositories; namespace Omika.Diagnostics.Tests.Fakes; /// /// Repository in-memory (sans PostgreSQL, §51) : reproduit les mêmes contrats que /// NpgsqlDiagnosticEventRepository (déduplication par event_id, tri, pagination). /// public sealed class FakeDiagnosticEventRepository : IDiagnosticEventRepository { private readonly Dictionary _byEventId = new(); private readonly List _all = new(); private readonly object _sync = new(); /// /// Identifiants URL-safe des groupes d'erreurs « traités » (tâche en statut terminal /// Résolu/Rejeté). Reproduit la jointure SQL de /// NpgsqlDiagnosticEventRepository.DeleteOlderThanAsync : seuls les événements /// de ces groupes sont éligibles à la purge. /// private readonly HashSet _treatedGroups = new(StringComparer.Ordinal); public int InsertedCount { get; private set; } /// Marque un groupe d'erreurs comme « traité » (tâche terminale), donc purgable. public void MarkGroupTreated(string groupUrlIdentifier) { lock (_sync) { _treatedGroups.Add(groupUrlIdentifier); } } public Task InsertBatchAsync( IReadOnlyList events, CancellationToken cancellationToken = default) { lock (_sync) { int inserted = 0; foreach (DiagnosticEventRecord e in events) { if (_byEventId.TryAdd(e.EventId, e)) { _all.Add(e); inserted++; } } InsertedCount += inserted; return Task.FromResult(inserted); } } public Task> QueryAsync( DiagnosticEventQuery query, CancellationToken cancellationToken = default) { List all; lock (_sync) { all = new List(_all); } IEnumerable filtered = all.Where(e => (query.FromUtc is null || e.TimestampUtc >= query.FromUtc) && (query.ToUtc is null || e.TimestampUtc <= query.ToUtc) && (query.ErrorsOnly ? DiagnosticErrorLevels.IsError(e.Level) : (string.IsNullOrWhiteSpace(query.Level) || e.Level == query.Level)) && (string.IsNullOrWhiteSpace(query.Priority) || e.Priority == query.Priority) && (string.IsNullOrWhiteSpace(query.Component) || e.Component == query.Component) && (query.SyncOnly ? DiagnosticSyncEventCodes.IsSyncEventCode(e.EventCode) : (string.IsNullOrWhiteSpace(query.EventCode) || e.EventCode == query.EventCode)) && (string.IsNullOrWhiteSpace(query.ErrorCode) || e.ErrorCode == query.ErrorCode) && (string.IsNullOrWhiteSpace(query.AppVersion) || e.AppVersion == query.AppVersion) && (string.IsNullOrWhiteSpace(query.Platform) || e.Platform == query.Platform) && (string.IsNullOrWhiteSpace(query.ActorKey) || e.ActorKey == query.ActorKey) && (string.IsNullOrWhiteSpace(query.ClientCode) || e.ClientCode == query.ClientCode) && (string.IsNullOrWhiteSpace(query.ServerInstance) || e.ServerInstance == query.ServerInstance) && (string.IsNullOrWhiteSpace(query.Environment) || e.Environment == query.Environment) && (string.IsNullOrWhiteSpace(query.LicenseNumber) || e.LicenseNumber == query.LicenseNumber) && (string.IsNullOrWhiteSpace(query.DeviceKey) || e.DeviceKey == query.DeviceKey) && (string.IsNullOrWhiteSpace(query.Manufacturer) || e.DeviceManufacturer == query.Manufacturer) && (string.IsNullOrWhiteSpace(query.DeviceModel) || e.DeviceModel == query.DeviceModel) && (string.IsNullOrWhiteSpace(query.OperatingSystem) || e.OperatingSystem == query.OperatingSystem) && (string.IsNullOrWhiteSpace(query.OperatingSystemVersion) || e.OperatingSystemVersion == query.OperatingSystemVersion) && (string.IsNullOrWhiteSpace(query.ScreenCode) || e.ScreenCode == query.ScreenCode) && (string.IsNullOrWhiteSpace(query.FeatureCode) || e.FeatureCode == query.FeatureCode)); List ordered = filtered .OrderByDescending(e => e.TimestampUtc) .ThenByDescending(e => e.Id) .ToList(); List page = ordered .Skip(query.Offset) .Take(query.PageSize) .ToList(); return Task.FromResult(new DiagnosticQueryResult { Items = page, TotalCount = ordered.Count, Page = query.Page, PageSize = query.PageSize }); } public Task> GetByTraceIdAsync( Guid traceId, CancellationToken cancellationToken = default) => Task.FromResult>( _all.Where(e => e.TraceId == traceId) .OrderBy(e => e.TimestampUtc) .ThenBy(e => e.Id) .ToList()); public Task> GetByOperationIdAsync( Guid operationId, CancellationToken cancellationToken = default) => Task.FromResult>( _all.Where(e => e.OperationId == operationId) .OrderBy(e => e.TimestampUtc) .ThenBy(e => e.Id) .ToList()); public Task GetByIdAsync( long id, CancellationToken cancellationToken = default) { DiagnosticEventRecord? match = _all.FirstOrDefault(e => e.Id == id); return Task.FromResult(match); } public Task> GetCorrelatedAsync( DiagnosticCorrelationQuery query, CancellationToken cancellationToken = default) { query.Validate(); if (!query.HasAnyIdentifier) { return Task.FromResult>([]); } IReadOnlyList matches = _all .Where(e => e.TimestampUtc >= query.FromUtc && e.TimestampUtc < query.ToUtc && ((query.TraceId is not null && e.TraceId == query.TraceId) || (query.OperationId is not null && e.OperationId == query.OperationId) || (query.SessionId is not null && e.SessionId == query.SessionId) || (!string.IsNullOrWhiteSpace(query.ActorKey) && e.ActorKey == query.ActorKey))) .ToList(); // Miroir de NpgsqlDiagnosticEventRepository.GetCorrelatedAsync : sans ancre, les // plus anciens de la fenêtre ; avec ancre, les plus proches de l'événement // consulté. Restitution chronologique dans les deux cas. IEnumerable selection = query.AnchorUtc is { } anchor ? matches .OrderBy(e => Math.Abs((e.TimestampUtc - anchor).Ticks)) .ThenBy(e => e.TimestampUtc) .ThenBy(e => e.Id) : matches .OrderBy(e => e.TimestampUtc) .ThenBy(e => e.Id); IReadOnlyList result = selection .Take(query.Limit) .OrderBy(e => e.TimestampUtc) .ThenBy(e => e.Id) .ToList(); return Task.FromResult(result); } public Task IsAvailableAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); public Task> GetFilterOptionsAsync( DiagnosticFilterDimension dimension, DateTimeOffset fromUtc, int limit, CancellationToken cancellationToken = default) { int safeLimit = Math.Clamp(limit, 1, DiagnosticAggregateDefaults.MaxLimit); Func selector = dimension switch { DiagnosticFilterDimension.EventCode => e => e.EventCode, DiagnosticFilterDimension.ErrorCode => e => e.ErrorCode, DiagnosticFilterDimension.Component => e => e.Component, DiagnosticFilterDimension.Platform => e => e.Platform, DiagnosticFilterDimension.AppVersion => e => e.AppVersion, DiagnosticFilterDimension.ClientCode => e => e.ClientCode, DiagnosticFilterDimension.ServerInstance => e => e.ServerInstance, DiagnosticFilterDimension.Environment => e => e.Environment, DiagnosticFilterDimension.Manufacturer => e => e.DeviceManufacturer, DiagnosticFilterDimension.DeviceModel => e => e.DeviceModel, DiagnosticFilterDimension.OperatingSystem => e => e.OperatingSystem, DiagnosticFilterDimension.OperatingSystemVersion => e => e.OperatingSystemVersion, DiagnosticFilterDimension.ScreenCode => e => e.ScreenCode, DiagnosticFilterDimension.FeatureCode => e => e.FeatureCode, _ => throw new ArgumentOutOfRangeException(nameof(dimension), dimension, "Dimension de filtre inconnue.") }; // Miroir de NpgsqlDiagnosticEventRepository.GetFilterOptionsAsync (T20 §3-5) : // Component/ErrorCode sont normalisés (mêmes règles que les agrégations) avant // déduplication, exactement comme la projection SQL canonique. Func normalize = dimension switch { DiagnosticFilterDimension.Component => DiagnosticDimensionNormalization.Component, DiagnosticFilterDimension.ErrorCode => DiagnosticDimensionNormalization.ErrorCode, _ => static v => v }; IReadOnlyList result = _all .Where(e => e.TimestampUtc >= fromUtc) .Select(selector) .Where(v => !string.IsNullOrWhiteSpace(v)) .Select(normalize) .Where(v => v is not null) .Select(v => v!) .Distinct(StringComparer.Ordinal) .OrderBy(v => v, StringComparer.Ordinal) .Take(safeLimit) .ToList(); return Task.FromResult(result); } public Task> GetRecentCorrelationsAsync( DiagnosticCorrelationDimension dimension, DateTimeOffset fromUtc, int limit, CancellationToken cancellationToken = default) { int safeLimit = Math.Clamp(limit, 1, DiagnosticAggregateDefaults.MaxLimit); Func idSelector = dimension switch { DiagnosticCorrelationDimension.Trace => e => e.TraceId, DiagnosticCorrelationDimension.Operation => e => e.OperationId, _ => throw new ArgumentOutOfRangeException(nameof(dimension), dimension, "Dimension de corrélation inconnue.") }; IReadOnlyList result = _all .Where(e => e.TimestampUtc >= fromUtc && idSelector(e) is not null) .GroupBy(e => idSelector(e)!.Value) .Select(g => new DiagnosticRecentCorrelation( g.Key, g.LongCount(), g.Min(e => e.TimestampUtc), g.Max(e => e.TimestampUtc), MinRaw(g.Select(e => e.Component)), MinRaw(g.Select(e => e.AppVersion)))) .OrderByDescending(x => x.LastSeenUtc) .ThenBy(x => x.CorrelationId) .Take(safeLimit) .ToList(); return Task.FromResult>(result); } public Task DeleteOlderThanAsync( DateTimeOffset cutoff, int batchSize, CancellationToken cancellationToken = default) { if (batchSize <= 0) { return Task.FromResult(0); } lock (_sync) { List toDelete = _all .Where(e => e.TimestampUtc < cutoff) // Règle de conservation (miroir de NpgsqlDiagnosticEventRepository) : // les NON-erreurs sont toujours purgées ; les ERREURS ne le sont que si // leur groupe est « traité » (tâche terminale). .Where(e => !DiagnosticErrorLevels.IsError(e.Level) || _treatedGroups.Contains(GroupIdentifier(e))) .OrderBy(e => e.TimestampUtc) .ThenBy(e => e.Id) .Take(batchSize) .ToList(); foreach (DiagnosticEventRecord e in toDelete) { _all.Remove(e); _byEventId.Remove(e.EventId); } return Task.FromResult(toDelete.Count); } } public Task DeleteErrorGroupAsync( DiagnosticErrorGroupIdentity identity, CancellationToken cancellationToken = default) { lock (_sync) { // Miroir de NpgsqlDiagnosticEventRepository.DeleteErrorGroupAsync : aucune // borne de période, identité normalisée (event_code + 3 clés) et restriction // aux niveaux d'erreur réels. Un événement non-erreur de même signature n'est // jamais supprimé. List toDelete = _all .Where(e => DiagnosticErrorLevels.IsError(e.Level)) .Where(e => e.EventCode == identity.EventCode) .Where(e => DiagnosticErrorGrouping.NormalizeKey(e.ErrorCode) == identity.NormalizedErrorCode) .Where(e => DiagnosticErrorGrouping.NormalizeKey(e.ExceptionType) == identity.NormalizedExceptionType) .Where(e => DiagnosticErrorGrouping.NormalizeKey(e.Component) == identity.NormalizedComponent) .ToList(); foreach (DiagnosticEventRecord e in toDelete) { _all.Remove(e); _byEventId.Remove(e.EventId); } return Task.FromResult((long)toDelete.Count); } } /// Identifiant URL-safe du groupe d'un événement (équivalent SQL de la purge). private static string GroupIdentifier(DiagnosticEventRecord e) => DiagnosticErrorGroupLink.Encode(new DiagnosticErrorGroupIdentity( e.EventCode, DiagnosticErrorGrouping.NormalizeKey(e.ErrorCode), DiagnosticErrorGrouping.NormalizeKey(e.ExceptionType), DiagnosticErrorGrouping.NormalizeKey(e.Component))); public Task> CountGroupedAsync( DiagnosticGroupDimension dimension, DiagnosticAggregateQuery query, CancellationToken cancellationToken = default) { query.Validate(); Func keySelector = dimension switch { DiagnosticGroupDimension.EventCode => e => e.EventCode, DiagnosticGroupDimension.ErrorCode => e => DiagnosticDimensionNormalization.ErrorCode(e.ErrorCode), DiagnosticGroupDimension.AppVersion => e => e.AppVersion, DiagnosticGroupDimension.Component => e => DiagnosticDimensionNormalization.Component(e.Component), DiagnosticGroupDimension.Level => e => DiagnosticLevels.Normalize(e.Level), DiagnosticGroupDimension.Priority => e => e.Priority, DiagnosticGroupDimension.Platform => e => e.Platform, DiagnosticGroupDimension.Source => e => e.Source, _ => throw new ArgumentOutOfRangeException(nameof(dimension), dimension, "Dimension de groupement inconnue.") }; IReadOnlyList result = Filter(query) .GroupBy(keySelector) .Select(g => new DiagnosticGroupCount(g.Key, g.LongCount())) .OrderByDescending(x => x.Count) .ThenBy(x => x.Key, StringComparer.Ordinal) .Take(query.Limit) .ToList(); return Task.FromResult(result); } public Task> CountByTimeBucketAsync( DiagnosticTimeBucket bucket, DiagnosticAggregateQuery query, CancellationToken cancellationToken = default) { query.Validate(); Func truncate = bucket switch { DiagnosticTimeBucket.Hour => t => new DateTimeOffset(t.Year, t.Month, t.Day, t.Hour, 0, 0, t.Offset), DiagnosticTimeBucket.Day => t => new DateTimeOffset(t.Year, t.Month, t.Day, 0, 0, 0, t.Offset), _ => throw new ArgumentOutOfRangeException(nameof(bucket), bucket, "Bucket temporel inconnu.") }; IReadOnlyList result = Filter(query) .GroupBy(e => truncate(e.TimestampUtc)) .Select(g => new DiagnosticBucketCount(g.Key, g.LongCount())) .OrderBy(x => x.BucketUtc) .ToList(); return Task.FromResult(result); } public Task> CountByTimeBucketAndClientAsync( DiagnosticTimeBucket bucket, DiagnosticAggregateQuery query, CancellationToken cancellationToken = default) { query.Validate(); Func truncate = bucket switch { DiagnosticTimeBucket.Hour => t => new DateTimeOffset(t.Year, t.Month, t.Day, t.Hour, 0, 0, t.Offset), DiagnosticTimeBucket.Day => t => new DateTimeOffset(t.Year, t.Month, t.Day, 0, 0, 0, t.Offset), _ => throw new ArgumentOutOfRangeException(nameof(bucket), bucket, "Bucket temporel inconnu.") }; // Miroir du NULLIF(TRIM(client_code), '') SQL : NULL/vides/espaces → groupe null. IReadOnlyList result = Filter(query) .GroupBy(e => (Bucket: truncate(e.TimestampUtc), Client: NormalizeClient(e.ClientCode))) .Select(g => new DiagnosticBucketClientCount(g.Key.Bucket, g.Key.Client, g.LongCount())) .OrderBy(x => x.BucketUtc) .ThenBy(x => x.ClientCode is null) .ThenBy(x => x.ClientCode, StringComparer.OrdinalIgnoreCase) .ToList(); return Task.FromResult(result); } public Task> CountLicensesByErrorsAsync( DiagnosticAggregateQuery query, CancellationToken cancellationToken = default) { query.Validate(); // Miroir du GROUP BY NULLIF(TRIM(license_number), '') + prédicat d'erreur réel. IReadOnlyList result = Filter(query) .Where(e => DiagnosticErrorLevels.IsError(e.Level)) .Where(e => !string.IsNullOrWhiteSpace(e.LicenseNumber)) .GroupBy(e => e.LicenseNumber!.Trim()) .Select(g => new DiagnosticLicenseErrorCount(g.Key, g.LongCount())) .OrderByDescending(x => x.ErrorCount) .ThenBy(x => x.LicenseNumber, StringComparer.Ordinal) .Take(query.Limit) .ToList(); return Task.FromResult(result); } public Task> CountErrorsByClientAsync( DiagnosticAggregateQuery query, CancellationToken cancellationToken = default) { query.Validate(); // Miroir du GROUP BY NULLIF(TRIM(client_code), '') + prédicat d'erreur réel. // NULL/vides/espaces → groupe null (rendu « Non renseigné »). IReadOnlyList result = Filter(query) .Where(e => DiagnosticErrorLevels.IsError(e.Level)) .GroupBy(e => NormalizeClient(e.ClientCode)) .Select(g => new DiagnosticGroupCount(g.Key, g.LongCount())) .OrderByDescending(x => x.Count) .ThenBy(x => x.Key is null) .ThenBy(x => x.Key, StringComparer.OrdinalIgnoreCase) .Take(query.Limit) .ToList(); return Task.FromResult(result); } public Task> SearchAsync( DiagnosticSearchQuery query, CancellationToken cancellationToken = default) { query.Validate(); // Même règle de normalisation que l'implémentation PostgreSQL (§T2). string? term = DiagnosticSearchDefaults.Normalize(query.SearchText); List all; lock (_sync) { all = new List(_all); } IEnumerable filtered = all.Where(e => e.TimestampUtc >= query.FromUtc!.Value && e.TimestampUtc < query.ToUtc!.Value && (term is null || MatchesText(e, term)) && (string.IsNullOrWhiteSpace(query.Level) || e.Level == query.Level) && (string.IsNullOrWhiteSpace(query.Priority) || e.Priority == query.Priority) && (string.IsNullOrWhiteSpace(query.Component) || e.Component == query.Component) && (string.IsNullOrWhiteSpace(query.EventCode) || e.EventCode == query.EventCode) && (string.IsNullOrWhiteSpace(query.ErrorCode) || e.ErrorCode == query.ErrorCode) && (string.IsNullOrWhiteSpace(query.AppVersion) || e.AppVersion == query.AppVersion) && (string.IsNullOrWhiteSpace(query.Platform) || e.Platform == query.Platform) && (string.IsNullOrWhiteSpace(query.ActorKey) || e.ActorKey == query.ActorKey) && (string.IsNullOrWhiteSpace(query.ClientCode) || e.ClientCode == query.ClientCode) && (string.IsNullOrWhiteSpace(query.ServerInstance) || e.ServerInstance == query.ServerInstance) && (string.IsNullOrWhiteSpace(query.Environment) || e.Environment == query.Environment) && (string.IsNullOrWhiteSpace(query.LicenseNumber) || e.LicenseNumber == query.LicenseNumber) && (string.IsNullOrWhiteSpace(query.DeviceKey) || e.DeviceKey == query.DeviceKey) && (string.IsNullOrWhiteSpace(query.Manufacturer) || e.DeviceManufacturer == query.Manufacturer) && (string.IsNullOrWhiteSpace(query.DeviceModel) || e.DeviceModel == query.DeviceModel) && (string.IsNullOrWhiteSpace(query.OperatingSystem) || e.OperatingSystem == query.OperatingSystem) && (string.IsNullOrWhiteSpace(query.OperatingSystemVersion) || e.OperatingSystemVersion == query.OperatingSystemVersion) && (string.IsNullOrWhiteSpace(query.ScreenCode) || e.ScreenCode == query.ScreenCode) && (string.IsNullOrWhiteSpace(query.FeatureCode) || e.FeatureCode == query.FeatureCode)); List ordered = filtered .OrderByDescending(e => e.TimestampUtc) .ThenByDescending(e => e.Id) .ToList(); List page = ordered .Skip(query.Offset) .Take(query.PageSize) .ToList(); return Task.FromResult(new DiagnosticQueryResult { Items = page, TotalCount = ordered.Count, Page = query.Page, PageSize = query.PageSize }); } public Task> GroupErrorsAsync( DiagnosticErrorGroupQuery query, CancellationToken cancellationToken = default) { query.Validate(); IReadOnlyList result = FilterGroup(query) .GroupBy(e => ( EventCode: e.EventCode, ErrorCode: DiagnosticErrorGrouping.NormalizeKey(e.ErrorCode), ExceptionType: DiagnosticErrorGrouping.NormalizeKey(e.ExceptionType), Component: DiagnosticErrorGrouping.NormalizeKey(e.Component))) .Select(g => new DiagnosticErrorGroup { GroupKey = DiagnosticErrorGrouping.BuildGroupKey( g.Key.EventCode, g.Key.ErrorCode, g.Key.ExceptionType, g.Key.Component), UrlIdentifier = DiagnosticErrorGroupLink.Encode( new DiagnosticErrorGroupIdentity( g.Key.EventCode, g.Key.ErrorCode, g.Key.ExceptionType, g.Key.Component)), EventCode = g.Key.EventCode, ErrorCode = MinRaw(g.Select(e => e.ErrorCode)), ExceptionType = MinRaw(g.Select(e => e.ExceptionType)), Component = MinRaw(g.Select(e => e.Component)), OccurrenceCount = g.LongCount(), FirstSeenUtc = g.Min(e => e.TimestampUtc), LastSeenUtc = g.Max(e => e.TimestampUtc), AppVersionCount = g.Select(e => e.AppVersion).Where(v => v is not null).Distinct().LongCount(), ActorCount = g.Select(e => e.ActorKey).Where(v => v is not null).Distinct().LongCount() }) .OrderByDescending(x => x.OccurrenceCount) .ThenBy(x => x.EventCode, StringComparer.Ordinal) .ThenBy(x => x.ExceptionType is null) .ThenBy(x => x.ExceptionType, StringComparer.Ordinal) .ThenBy(x => x.ErrorCode is null) .ThenBy(x => x.ErrorCode, StringComparer.Ordinal) .ThenBy(x => x.Component is null) .ThenBy(x => x.Component, StringComparer.Ordinal) .Take(query.Limit) .ToList(); return Task.FromResult(result); } /// /// Équivalent du message ILIKE … OR exception_type ILIKE … PostgreSQL : /// sous-chaîne insensible à la casse, terme littéral (aucun joker). /// private static bool MatchesText(DiagnosticEventRecord e, string term) => (e.Message is not null && e.Message.Contains(term, StringComparison.OrdinalIgnoreCase)) || (e.ExceptionType is not null && e.ExceptionType.Contains(term, StringComparison.OrdinalIgnoreCase)); /// /// Équivalent du MIN(col) PostgreSQL (ignorant les NULL) sur une colonne /// texte : plus petite valeur non NULL en comparaison ordinale, null si tout /// est NULL. Utilisé comme valeur REPRÉSENTATIVE brute d'un groupe (§T2). /// private static string? MinRaw(IEnumerable values) { string? min = null; foreach (string? v in values) { if (v is null) { continue; } if (min is null || string.CompareOrdinal(v, min) < 0) { min = v; } } return min; } /// Équivalent du NULLIF(TRIM(client_code), '') PostgreSQL. private static string? NormalizeClient(string? clientCode) => string.IsNullOrWhiteSpace(clientCode) ? null : clientCode.Trim(); private IEnumerable Filter(DiagnosticAggregateQuery query) { DateTimeOffset from = query.FromUtc!.Value; // Validate() garanti la présence. DateTimeOffset to = query.ToUtc!.Value; return _all.Where(e => e.TimestampUtc >= from && e.TimestampUtc < to && (query.ErrorsOnly ? DiagnosticErrorLevels.IsError(e.Level) : (string.IsNullOrWhiteSpace(query.Level) || e.Level == query.Level)) && (string.IsNullOrWhiteSpace(query.Priority) || e.Priority == query.Priority) && (string.IsNullOrWhiteSpace(query.Component) || e.Component == query.Component) && (query.SyncOnly ? DiagnosticSyncEventCodes.IsSyncEventCode(e.EventCode) : (string.IsNullOrWhiteSpace(query.EventCode) || e.EventCode == query.EventCode)) && (query.HasErrorCode ? !string.IsNullOrWhiteSpace(e.ErrorCode) : true) && (string.IsNullOrWhiteSpace(query.ErrorCode) || e.ErrorCode == query.ErrorCode) && (string.IsNullOrWhiteSpace(query.AppVersion) || e.AppVersion == query.AppVersion) && (string.IsNullOrWhiteSpace(query.Platform) || e.Platform == query.Platform) && (string.IsNullOrWhiteSpace(query.ActorKey) || e.ActorKey == query.ActorKey) && (string.IsNullOrWhiteSpace(query.ClientCode) || e.ClientCode == query.ClientCode) && (string.IsNullOrWhiteSpace(query.ServerInstance) || e.ServerInstance == query.ServerInstance) && (string.IsNullOrWhiteSpace(query.Environment) || e.Environment == query.Environment)); } /// /// Filtre partagé des requêtes de regroupement d'erreurs (§T2/T4) : période obligatoire /// + filtres d'égalité + prédicat ErrorsOnly (niveaux d'erreur réels). /// private IEnumerable FilterGroup(DiagnosticErrorGroupQuery query) { DateTimeOffset from = query.FromUtc!.Value; // Validate() garanti la présence. DateTimeOffset to = query.ToUtc!.Value; return _all.Where(e => e.TimestampUtc >= from && e.TimestampUtc < to && (query.ErrorsOnly ? DiagnosticErrorLevels.IsError(e.Level) : (string.IsNullOrWhiteSpace(query.Level) || e.Level == query.Level)) && (string.IsNullOrWhiteSpace(query.Priority) || e.Priority == query.Priority) && (string.IsNullOrWhiteSpace(query.Component) || e.Component == query.Component) && (string.IsNullOrWhiteSpace(query.EventCode) || e.EventCode == query.EventCode) && (string.IsNullOrWhiteSpace(query.ErrorCode) || e.ErrorCode == query.ErrorCode) && (string.IsNullOrWhiteSpace(query.AppVersion) || e.AppVersion == query.AppVersion) && (string.IsNullOrWhiteSpace(query.Platform) || e.Platform == query.Platform) && (string.IsNullOrWhiteSpace(query.ActorKey) || e.ActorKey == query.ActorKey) && (string.IsNullOrWhiteSpace(query.ClientCode) || e.ClientCode == query.ClientCode) && (string.IsNullOrWhiteSpace(query.ServerInstance) || e.ServerInstance == query.ServerInstance) && (string.IsNullOrWhiteSpace(query.Environment) || e.Environment == query.Environment)); } public Task CountAsync( DiagnosticAggregateQuery query, CancellationToken cancellationToken = default) { query.Validate(); return Task.FromResult(Filter(query).LongCount()); } public Task CountErrorGroupsAsync( DiagnosticErrorGroupQuery query, CancellationToken cancellationToken = default) { query.Validate(); long count = FilterGroup(query) .Select(e => ( EventCode: e.EventCode, ErrorCode: DiagnosticErrorGrouping.NormalizeKey(e.ErrorCode), ExceptionType: DiagnosticErrorGrouping.NormalizeKey(e.ExceptionType), Component: DiagnosticErrorGrouping.NormalizeKey(e.Component))) .Distinct() .LongCount(); return Task.FromResult(count); } public Task GetDurationStatsAsync( DiagnosticAggregateQuery query, CancellationToken cancellationToken = default) { query.Validate(); List durations = Filter(query) .Where(e => e.DurationMs is not null) .Select(e => e.DurationMs!.Value) .ToList(); if (durations.Count == 0) { return Task.FromResult(null); // absence ≠ zéro. } return Task.FromResult(new DiagnosticDurationStats( durations.Count, durations.Average(), durations.Min(), durations.Max())); } public Task> GetTopDurationByEventCodeAsync( DiagnosticAggregateQuery query, CancellationToken cancellationToken = default) { query.Validate(); IReadOnlyList result = Filter(query) .Where(e => e.DurationMs is not null) .GroupBy(e => e.EventCode) .Select(g => new DiagnosticDurationByEventCode( g.Key, g.LongCount(), g.Average(e => e.DurationMs!.Value), g.Max(e => e.DurationMs!.Value))) .OrderByDescending(x => x.AverageMs) .ThenBy(x => x.EventCode, StringComparer.Ordinal) .Take(query.Limit) .ToList(); return Task.FromResult(result); } public Task> GetVersionSummariesAsync( DiagnosticAggregateQuery query, CancellationToken cancellationToken = default) { query.Validate(); IReadOnlyList result = Filter(query) .Where(e => !string.IsNullOrEmpty(e.AppVersion)) // NULL/vide exclus (miroir NULLIF) .GroupBy(e => e.AppVersion!) .Select(g => new DiagnosticVersionSummary( g.Key, g.LongCount(), g.LongCount(e => DiagnosticErrorLevels.IsError(e.Level)), g.Min(e => e.TimestampUtc), g.Max(e => e.TimestampUtc), g.Select(e => e.Platform).Where(p => p is not null).Distinct().LongCount())) .OrderByDescending(x => x.EventCount) .ThenBy(x => x.AppVersion, StringComparer.Ordinal) .Take(query.Limit) .ToList(); return Task.FromResult(result); } public Task GetVersionTotalsAsync( DiagnosticAggregateQuery query, CancellationToken cancellationToken = default) { query.Validate(); List filtered = Filter(query).ToList(); List versioned = filtered.Where(e => !string.IsNullOrEmpty(e.AppVersion)).ToList(); return Task.FromResult(new DiagnosticVersionTotals( versioned.Select(e => e.AppVersion).Distinct().LongCount(), versioned.LongCount(), versioned.LongCount(e => DiagnosticErrorLevels.IsError(e.Level)), filtered.LongCount(e => string.IsNullOrEmpty(e.AppVersion)))); } public Task GetSyncSummaryAsync( DiagnosticAggregateQuery query, CancellationToken cancellationToken = default) { query.Validate(); List sync = _all .Where(e => e.TimestampUtc >= query.FromUtc!.Value && e.TimestampUtc < query.ToUtc!.Value && DiagnosticSyncEventCodes.IsSyncEventCode(e.EventCode)) .ToList(); long eventCount = sync.Count; long operationCount = sync .Where(e => e.OperationId is not null) .Select(e => e.OperationId!.Value) .Distinct() .LongCount(); long errorCount = sync.LongCount(e => DiagnosticErrorLevels.IsError(e.Level)); List durations = sync .Where(e => e.DurationMs is not null) .Select(e => e.DurationMs!.Value) .ToList(); long durationCount = durations.Count; return Task.FromResult(new DiagnosticSyncSummary( eventCount, operationCount, errorCount, durationCount, durationCount == 0 ? null : durations.Average(), durationCount == 0 ? null : durations.Min(), durationCount == 0 ? null : durations.Max())); } public Task GetErrorGroupAsync( DiagnosticErrorGroupOccurrenceQuery query, CancellationToken cancellationToken = default) { query.Validate(); DiagnosticErrorGroup? group = Occurrences(query) .GroupBy(e => ( EventCode: e.EventCode, ErrorCode: DiagnosticErrorGrouping.NormalizeKey(e.ErrorCode), ExceptionType: DiagnosticErrorGrouping.NormalizeKey(e.ExceptionType), Component: DiagnosticErrorGrouping.NormalizeKey(e.Component))) .Select(g => new DiagnosticErrorGroup { GroupKey = DiagnosticErrorGrouping.BuildGroupKey( g.Key.EventCode, g.Key.ErrorCode, g.Key.ExceptionType, g.Key.Component), UrlIdentifier = DiagnosticErrorGroupLink.Encode( new DiagnosticErrorGroupIdentity( g.Key.EventCode, g.Key.ErrorCode, g.Key.ExceptionType, g.Key.Component)), EventCode = g.Key.EventCode, ErrorCode = MinRaw(g.Select(e => e.ErrorCode)), ExceptionType = MinRaw(g.Select(e => e.ExceptionType)), Component = MinRaw(g.Select(e => e.Component)), OccurrenceCount = g.LongCount(), FirstSeenUtc = g.Min(e => e.TimestampUtc), LastSeenUtc = g.Max(e => e.TimestampUtc), AppVersionCount = g.Select(e => e.AppVersion).Where(v => v is not null).Distinct().LongCount(), ActorCount = g.Select(e => e.ActorKey).Where(v => v is not null).Distinct().LongCount() }) .FirstOrDefault(); return Task.FromResult(group); } public Task> GetErrorGroupOccurrencesAsync( DiagnosticErrorGroupOccurrenceQuery query, CancellationToken cancellationToken = default) { query.Validate(); IReadOnlyList result = Occurrences(query) .OrderByDescending(e => e.TimestampUtc) .ThenByDescending(e => e.Id) .Skip(query.Offset) .Take(query.PageSize) .ToList(); return Task.FromResult(result); } public Task> CountErrorGroupByClientAsync( DiagnosticErrorGroupOccurrenceQuery query, int limit, CancellationToken cancellationToken = default) { query.Validate(); int safeLimit = Math.Clamp(limit, 1, DiagnosticAggregateDefaults.MaxLimit); // Miroir du NULLIF(TRIM(client_code), '') SQL : NULL/vides/espaces → groupe null. IReadOnlyList result = Occurrences(query) .GroupBy(e => NormalizeClient(e.ClientCode)) .Select(g => new DiagnosticGroupCount(g.Key, g.LongCount())) .OrderByDescending(x => x.Count) .ThenBy(x => x.Key is null) .ThenBy(x => x.Key, StringComparer.OrdinalIgnoreCase) .Take(safeLimit) .ToList(); return Task.FromResult(result); } public Task> CountErrorGroupByLicenseAsync( DiagnosticErrorGroupOccurrenceQuery query, int limit, CancellationToken cancellationToken = default) { query.Validate(); int safeLimit = Math.Clamp(limit, 1, DiagnosticAggregateDefaults.MaxLimit); // Miroir du GROUP BY NULLIF(TRIM(license_number), '') : NULL/vides exclus. IReadOnlyList result = Occurrences(query) .Where(e => !string.IsNullOrWhiteSpace(e.LicenseNumber)) .GroupBy(e => e.LicenseNumber!.Trim()) .Select(g => new DiagnosticLicenseErrorCount(g.Key, g.LongCount())) .OrderByDescending(x => x.ErrorCount) .ThenBy(x => x.LicenseNumber, StringComparer.Ordinal) .Take(safeLimit) .ToList(); return Task.FromResult(result); } /// /// Filtre les événements d'un groupe précis (§T5) : période obligatoire + identité /// normalisée. Reproduit EXACTEMENT la normalisation du regroupement (NormalizeKey, /// équivalent de LOWER(NULLIF(TRIM(col),''))), y compris le NULL (null == null). /// private IEnumerable Occurrences(DiagnosticErrorGroupOccurrenceQuery query) { DateTimeOffset from = query.FromUtc; // Validate() garanti l'ordre. DateTimeOffset to = query.ToUtc; return _all.Where(e => e.TimestampUtc >= from && e.TimestampUtc < to && e.EventCode == query.EventCode && DiagnosticErrorGrouping.NormalizeKey(e.ErrorCode) == query.NormalizedErrorCode && DiagnosticErrorGrouping.NormalizeKey(e.ExceptionType) == query.NormalizedExceptionType && DiagnosticErrorGrouping.NormalizeKey(e.Component) == query.NormalizedComponent); } // ============================================================ // T17-E — Statistiques de parc (identité appareil = DeviceKey). // ============================================================ public Task CountActiveDevicesAsync( DeviceFleetQuery query, CancellationToken cancellationToken = default) { query.Validate(); return Task.FromResult(LatestDeviceEvents(query).LongCount()); } public Task> CountDevicesGroupedAsync( DeviceFleetDimension dimension, DeviceFleetQuery query, CancellationToken cancellationToken = default) { query.Validate(); Func keySelector = dimension switch { DeviceFleetDimension.Platform => e => DeviceFleetNormalization.Platform(e.Platform), DeviceFleetDimension.OperatingSystem => e => DeviceFleetNormalization.OperatingSystem(e.OperatingSystem), DeviceFleetDimension.OperatingSystemVersion => e => DeviceFleetNormalization.OperatingSystemVersionKey(e.OperatingSystem, e.OperatingSystemVersion), DeviceFleetDimension.Manufacturer => e => DeviceFleetNormalization.Manufacturer(e.DeviceManufacturer), DeviceFleetDimension.DeviceModel => e => DeviceFleetNormalization.DeviceModelKey(e.DeviceManufacturer, e.DeviceModel), DeviceFleetDimension.AppVersion => e => DeviceFleetNormalization.AppVersion(e.AppVersion), DeviceFleetDimension.Resolution => e => DeviceFleetNormalization.ResolutionKey(e.ScreenWidth, e.ScreenHeight), _ => throw new ArgumentOutOfRangeException(nameof(dimension), dimension, "Dimension de parc inconnue.") }; IReadOnlyList result = LatestDeviceEvents(query) .Select(keySelector) .Where(k => k is not null) .GroupBy(k => k!) .Select(g => new DeviceDistribution(g.Key, g.LongCount())) .OrderByDescending(x => x.DeviceCount) .ThenBy(x => x.Key, StringComparer.Ordinal) .Take(query.Limit) .ToList(); return Task.FromResult(result); } public Task GetDevicesAsync( DeviceFleetQuery query, CancellationToken cancellationToken = default) { query.Validate(); List all = LatestDeviceEvents(query) .OrderByDescending(e => e.TimestampUtc) .ThenBy(e => e.DeviceKey, StringComparer.Ordinal) .Select(ToFleetRecord) .ToList(); List page = all .Skip(query.Offset) .Take(query.PageSize) .ToList(); return Task.FromResult(new DeviceFleetQueryResult { Items = page, TotalCount = all.Count, Page = query.Page, PageSize = query.PageSize }); } public Task> CountDevicesByLicenseAsync( DeviceFleetQuery query, CancellationToken cancellationToken = default) { query.Validate(); DateTimeOffset from = query.FromUtc!.Value; DateTimeOffset to = query.ToUtc!.Value; IReadOnlyList result = _all .Where(e => e.TimestampUtc >= from && e.TimestampUtc < to && !string.IsNullOrWhiteSpace(e.DeviceKey) && !string.IsNullOrWhiteSpace(e.LicenseNumber)) .Where(e => string.IsNullOrWhiteSpace(query.ClientCode) || e.ClientCode == query.ClientCode) .Where(e => string.IsNullOrWhiteSpace(query.Environment) || e.Environment == query.Environment) .Where(e => string.IsNullOrWhiteSpace(query.LicenseNumber) || e.LicenseNumber == query.LicenseNumber) .GroupBy(e => e.LicenseNumber!.Trim()) .Select(g => new DeviceDistribution( g.Key, g.Select(e => e.DeviceKey).Where(k => k is not null).Distinct(StringComparer.Ordinal).LongCount())) .OrderByDescending(x => x.DeviceCount) .ThenBy(x => x.Key, StringComparer.Ordinal) .Take(query.Limit) .ToList(); return Task.FromResult(result); } public Task> GetScreenUsageAsync( UsageTelemetryQuery query, CancellationToken cancellationToken = default) { query.Validate(); DateTimeOffset from = query.FromUtc!.Value; DateTimeOffset to = query.ToUtc!.Value; string screenView = TelemetryEventCodes.MobileScreenView.Value!; IReadOnlyList result = _all .Where(e => e.TimestampUtc >= from && e.TimestampUtc < to) .Where(e => e.EventCode == screenView) .Where(e => !string.IsNullOrWhiteSpace(e.ScreenCode)) .Where(e => MatchesUsageFilters(e, query)) .GroupBy(e => e.ScreenCode!.Trim().ToUpperInvariant()) .Select(g => new ScreenUsageRecord { ScreenCode = g.Key, Views = g.LongCount(), DistinctDevices = g.Select(e => e.DeviceKey).Where(k => k is not null).Distinct(StringComparer.Ordinal).LongCount(), DistinctSessions = g.Select(e => e.SessionId).Where(s => s is not null).Distinct().LongCount() }) .OrderByDescending(x => x.Views) .ThenBy(x => x.ScreenCode, StringComparer.Ordinal) .Take(query.Limit) .ToList(); return Task.FromResult(result); } public Task> GetFeatureUsageAsync( UsageTelemetryQuery query, CancellationToken cancellationToken = default) { query.Validate(); DateTimeOffset from = query.FromUtc!.Value; DateTimeOffset to = query.ToUtc!.Value; string featureUse = TelemetryEventCodes.MobileFeatureUse.Value!; IReadOnlyList result = _all .Where(e => e.TimestampUtc >= from && e.TimestampUtc < to) .Where(e => e.EventCode == featureUse) .Where(e => !string.IsNullOrWhiteSpace(e.FeatureCode)) .Where(e => MatchesUsageFilters(e, query)) .GroupBy(e => e.FeatureCode!.Trim().ToUpperInvariant()) .Select(g => new FeatureUsageRecord { FeatureCode = g.Key, Uses = g.LongCount(), DistinctDevices = g.Select(e => e.DeviceKey).Where(k => k is not null).Distinct(StringComparer.Ordinal).LongCount() }) .OrderByDescending(x => x.Uses) .ThenBy(x => x.FeatureCode, StringComparer.Ordinal) .Take(query.Limit) .ToList(); return Task.FromResult(result); } /// Filtres d'usage (T18, §57) : tous facultatifs, combinables (AND). private static bool MatchesUsageFilters(DiagnosticEventRecord e, UsageTelemetryQuery q) => (string.IsNullOrWhiteSpace(q.ClientCode) || e.ClientCode == q.ClientCode) && (string.IsNullOrWhiteSpace(q.LicenseNumber) || e.LicenseNumber == q.LicenseNumber) && (string.IsNullOrWhiteSpace(q.Platform) || e.Platform == q.Platform) && (string.IsNullOrWhiteSpace(q.OperatingSystem) || e.OperatingSystem == q.OperatingSystem) && (string.IsNullOrWhiteSpace(q.AppVersion) || e.AppVersion == q.AppVersion) && (string.IsNullOrWhiteSpace(q.Environment) || e.Environment == q.Environment) && (string.IsNullOrWhiteSpace(q.ScreenCode) || e.ScreenCode == q.ScreenCode) && (string.IsNullOrWhiteSpace(q.FeatureCode) || e.FeatureCode == q.FeatureCode); /// /// « Dernière observation de chaque DeviceKey » (miroir du CTE PostgreSQL, §13) : /// filtres de scope ÉVÉNEMENT (période, client, environnement, licence) appliqués /// AVANT le choix de l'événement le plus récent ; filtres de scope DERNIÈRE /// OBSERVATION appliqués APRÈS (§47). Jamais de fusion par LicenseNumber (§14). /// private IEnumerable LatestDeviceEvents(DeviceFleetQuery query) { DateTimeOffset from = query.FromUtc!.Value; DateTimeOffset to = query.ToUtc!.Value; return _all .Where(e => e.TimestampUtc >= from && e.TimestampUtc < to && !string.IsNullOrWhiteSpace(e.DeviceKey)) .Where(e => string.IsNullOrWhiteSpace(query.ClientCode) || e.ClientCode == query.ClientCode) .Where(e => string.IsNullOrWhiteSpace(query.Environment) || e.Environment == query.Environment) .Where(e => string.IsNullOrWhiteSpace(query.LicenseNumber) || e.LicenseNumber == query.LicenseNumber) .GroupBy(e => e.DeviceKey!) .Select(g => g.OrderByDescending(e => e.TimestampUtc).ThenByDescending(e => e.Id).First()) .Where(e => MatchesLastObservation(e, query)); } private static bool MatchesLastObservation(DiagnosticEventRecord e, DeviceFleetQuery q) => (string.IsNullOrWhiteSpace(q.Platform) || e.Platform == q.Platform) && (string.IsNullOrWhiteSpace(q.OperatingSystem) || e.OperatingSystem == q.OperatingSystem) && (string.IsNullOrWhiteSpace(q.OperatingSystemVersion) || e.OperatingSystemVersion == q.OperatingSystemVersion) && (string.IsNullOrWhiteSpace(q.Manufacturer) || e.DeviceManufacturer == q.Manufacturer) && (string.IsNullOrWhiteSpace(q.DeviceModel) || e.DeviceModel == q.DeviceModel) && (string.IsNullOrWhiteSpace(q.AppVersion) || e.AppVersion == q.AppVersion); private static DeviceFleetRecord ToFleetRecord(DiagnosticEventRecord e) => new() { DeviceKey = e.DeviceKey!, LastSeenUtc = e.TimestampUtc, ClientCode = e.ClientCode, LicenseNumber = e.LicenseNumber, Environment = e.Environment, Platform = e.Platform, OperatingSystem = e.OperatingSystem, OperatingSystemVersion = e.OperatingSystemVersion, DeviceManufacturer = e.DeviceManufacturer, DeviceModel = e.DeviceModel, AppVersion = e.AppVersion, AppBuild = e.AppBuild, ScreenWidth = e.ScreenWidth, ScreenHeight = e.ScreenHeight, LogicalWidth = e.LogicalWidth, LogicalHeight = e.LogicalHeight, DisplayScale = e.DisplayScale }; }