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
};
}