using System.Text.Json; using MongoDB.Bson; using MongoDB.Driver; using SeriLoog.Models; namespace SeriLoog.Services; public sealed class LogQuery { public int Page { get; set; } = 1; public int PageSize { get; set; } = 25; public string[]? Levels { get; set; } public string? Search { get; set; } public DateTimeOffset? From { get; set; } public DateTimeOffset? To { get; set; } public string Source { get; set; } = "mongo"; } public sealed class LogQueryService(LogStoreOptions options, ILogger logger) { public async Task QueryAsync(LogQuery query, CancellationToken ct) { if (string.Equals(query.Source, "file", StringComparison.OrdinalIgnoreCase)) { return QueryFile(query); } try { return await QueryMongoAsync(query, ct); } catch (Exception ex) { logger.LogWarning(ex, "MongoDB unavailable, falling back to file log source"); var result = QueryFile(query); result.Warning = $"MongoDB unavailable ({ex.Message}). Showing file log instead."; return result; } } private async Task QueryMongoAsync(LogQuery query, CancellationToken ct) { var client = new MongoClient(options.MongoConnectionString); var db = client.GetDatabase(options.MongoDatabase); var collection = db.GetCollection(options.MongoCollection); var filters = new List>(); var builder = Builders.Filter; if (query.Levels is { Length: > 0 }) { var levelFilters = query.Levels.Select(l => builder.Regex("Level", new BsonRegularExpression($"^{System.Text.RegularExpressions.Regex.Escape(l)}", "i"))); filters.Add(builder.Or(levelFilters)); } if (!string.IsNullOrWhiteSpace(query.Search)) { var pattern = new BsonRegularExpression(System.Text.RegularExpressions.Regex.Escape(query.Search), "i"); filters.Add(builder.Or( builder.Regex("RenderedMessage", pattern), builder.Regex("MessageTemplate", pattern), builder.Regex("Exception", pattern))); } if (query.From is not null) { filters.Add(builder.Gte("Timestamp", query.From.Value.UtcDateTime)); } if (query.To is not null) { filters.Add(builder.Lte("Timestamp", query.To.Value.UtcDateTime)); } var filter = filters.Count > 0 ? builder.And(filters) : builder.Empty; var total = await collection.CountDocumentsAsync(filter, cancellationToken: ct); var page = Math.Max(1, query.Page); var pageSize = Math.Clamp(query.PageSize, 1, 500); var docs = await collection.Find(filter) .SortByDescending(d => d["Timestamp"]) .Skip((page - 1) * pageSize) .Limit(pageSize) .ToListAsync(ct); var items = docs.Select(MapMongoDocument).ToList(); return new LogQueryResult { Items = items, Total = total, Page = page, PageSize = pageSize, Source = "mongo" }; } private static LogEntryDto MapMongoDocument(BsonDocument doc) { string GetString(params string[] names) { foreach (var name in names) { if (doc.TryGetValue(name, out var v) && !v.IsBsonNull) return v.IsString ? v.AsString : v.ToString() ?? ""; } return ""; } DateTimeOffset GetTimestamp() { foreach (var name in new[] { "Timestamp", "UtcTimeStamp", "@t" }) { if (doc.TryGetValue(name, out var v) && !v.IsBsonNull) { return v.BsonType == BsonType.DateTime ? new DateTimeOffset(v.ToUniversalTime(), TimeSpan.Zero) : DateTimeOffset.Parse(v.ToString()!); } } return DateTimeOffset.UtcNow; } var properties = new Dictionary(); if (doc.TryGetValue("Properties", out var propsVal) && propsVal.IsBsonDocument) { foreach (var el in propsVal.AsBsonDocument) { properties[el.Name] = BsonTypeMapper.MapToDotNetValue(el.Value); } } var id = doc.TryGetValue("_id", out var idVal) ? idVal.ToString() ?? "" : ""; var json = doc.ToJson(new MongoDB.Bson.IO.JsonWriterSettings { Indent = true }); return new LogEntryDto { Id = id ?? "", Timestamp = GetTimestamp(), Level = NormalizeLevel(GetString("Level")), Message = GetString("RenderedMessage", "MessageTemplate"), MessageTemplate = GetString("MessageTemplate"), Exception = doc.TryGetValue("Exception", out var exVal) && !exVal.IsBsonNull ? exVal.ToString() : null, Properties = properties, Source = "mongo", RawJson = json }; } private LogQueryResult QueryFile(LogQuery query) { if (string.IsNullOrWhiteSpace(options.LogFilePath)) { return new LogQueryResult { Items = [], Total = 0, Page = Math.Max(1, query.Page), PageSize = Math.Clamp(query.PageSize, 1, 500), Source = "file", Warning = "File logging is not configured (MongoDb:LogFilePath is empty)." }; } var dir = Path.GetDirectoryName(Path.GetFullPath(options.LogFilePath)) ?? "."; var baseName = Path.GetFileNameWithoutExtension(options.LogFilePath).TrimEnd('-'); var ext = Path.GetExtension(options.LogFilePath); var allLines = new List(); if (Directory.Exists(dir)) { var files = Directory.GetFiles(dir, $"{baseName}*{ext}").OrderByDescending(f => f); foreach (var f in files) { try { using var stream = new FileStream(f, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); using var reader = new StreamReader(stream); while (reader.ReadLine() is { } line) { if (!string.IsNullOrWhiteSpace(line)) allLines.Add(line); } } catch (IOException) { // file locked/rolling, skip } } } var entries = allLines.Select(ParseCompactJsonLine).Where(e => e is not null).Select(e => e!).ToList(); entries.Reverse(); // newest first (file lines are chronological) IEnumerable filtered = entries; if (query.Levels is { Length: > 0 }) { var set = new HashSet(query.Levels, StringComparer.OrdinalIgnoreCase); filtered = filtered.Where(e => set.Contains(e.Level)); } if (!string.IsNullOrWhiteSpace(query.Search)) { var s = query.Search; filtered = filtered.Where(e => e.Message.Contains(s, StringComparison.OrdinalIgnoreCase) || (e.Exception?.Contains(s, StringComparison.OrdinalIgnoreCase) ?? false)); } if (query.From is not null) { var from = query.From.Value; filtered = filtered.Where(e => e.Timestamp >= from); } if (query.To is not null) { var to = query.To.Value; filtered = filtered.Where(e => e.Timestamp <= to); } var list = filtered.ToList(); var total = list.Count; var page = Math.Max(1, query.Page); var pageSize = Math.Clamp(query.PageSize, 1, 500); var pageItems = list.Skip((page - 1) * pageSize).Take(pageSize).ToList(); return new LogQueryResult { Items = pageItems, Total = total, Page = page, PageSize = pageSize, Source = "file" }; } private static LogEntryDto? ParseCompactJsonLine(string line) { try { using var doc = JsonDocument.Parse(line); var root = doc.RootElement; var properties = new Dictionary(); string? messageTemplate = null; string? exception = null; var timestamp = DateTimeOffset.UtcNow; var level = "Information"; foreach (var prop in root.EnumerateObject()) { switch (prop.Name) { case "@t": timestamp = DateTimeOffset.Parse(prop.Value.GetString()!, null, System.Globalization.DateTimeStyles.AssumeUniversal); break; case "@mt": messageTemplate = prop.Value.GetString(); break; case "@l": level = prop.Value.GetString() ?? "Information"; break; case "@x": exception = prop.Value.GetString(); break; case "@m": case "@i": case "@r": break; default: properties[prop.Name] = JsonElementToObject(prop.Value); break; } } var message = RenderMessageTemplate(messageTemplate ?? "", properties); return new LogEntryDto { Id = Guid.NewGuid().ToString("N"), Timestamp = timestamp, Level = NormalizeLevel(level), Message = message, MessageTemplate = messageTemplate, Exception = exception, Properties = properties, Source = "file", RawJson = System.Text.Json.JsonSerializer.Serialize(JsonDocument.Parse(line).RootElement, new JsonSerializerOptions { WriteIndented = true }) }; } catch { return null; } } private static readonly System.Text.RegularExpressions.Regex TokenRegex = new(@"\{(@|\$)?(?[a-zA-Z_][a-zA-Z0-9_]*)(?:[^}]+)?\}", System.Text.RegularExpressions.RegexOptions.Compiled); private static string RenderMessageTemplate(string template, Dictionary properties) { return TokenRegex.Replace(template, match => { var name = match.Groups["name"].Value; if (!properties.TryGetValue(name, out var value)) return match.Value; var format = match.Groups["format"].Success ? match.Groups["format"].Value[1..] : null; if (format is not null && value is IFormattable formattable) { try { return formattable.ToString(format, System.Globalization.CultureInfo.InvariantCulture); } catch { /* fall through to plain rendering */ } } return value?.ToString() ?? ""; }); } private static object? JsonElementToObject(JsonElement el) => el.ValueKind switch { JsonValueKind.String => el.GetString(), JsonValueKind.Number => el.TryGetInt64(out var l) ? l : el.GetDouble(), JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.Null => null, _ => el.GetRawText() }; private static string NormalizeLevel(string level) => level switch { "Verbose" or "VRB" or "Trace" => "Verbose", "Debug" or "DBG" => "Debug", "Information" or "INF" or "Info" => "Information", "Warning" or "WRN" or "Warn" => "Warning", "Error" or "ERR" => "Error", "Fatal" or "FTL" or "Critical" => "Fatal", "" => "Information", _ => level }; }