Group repeated client logs for faster diagnosis
TaxBaik CI/CD / build-and-deploy (push) Has been cancelled

This commit is contained in:
2026-07-08 02:01:29 +09:00
parent d09c58d3f8
commit f502e88f89
3 changed files with 231 additions and 19 deletions
@@ -0,0 +1,147 @@
using FastEndpoints;
namespace TaxBaik.Web.Endpoints.ClientLogs;
public sealed class ClientLogGroupItem
{
public string Key { get; set; } = "";
public string? Level { get; set; }
public string? Source { get; set; }
public string? Route { get; set; }
public string? Message { get; set; }
public int Count { get; set; }
public string? LastSeen { get; set; }
}
public sealed class GetGroupedLogsEndpoint : EndpointWithoutRequest<IReadOnlyList<ClientLogGroupItem>>
{
private readonly IWebHostEnvironment _environment;
public GetGroupedLogsEndpoint(IWebHostEnvironment environment)
{
_environment = environment;
}
public override void Configure()
{
Get("/api/client-logs/groups");
Policies("AdminOnly");
}
public override async Task HandleAsync(CancellationToken ct)
{
var logDir = Path.Combine(_environment.ContentRootPath, "logs");
if (!Directory.Exists(logDir))
{
await SendAsync(Array.Empty<ClientLogGroupItem>(), cancellation: ct);
return;
}
var files = Directory.GetFiles(logDir, "taxbaik-*.log")
.OrderByDescending(File.GetLastWriteTimeUtc)
.Take(3)
.ToArray();
var groups = new Dictionary<string, ClientLogGroupItem>(StringComparer.OrdinalIgnoreCase);
foreach (var file in files)
{
foreach (var line in await File.ReadAllLinesAsync(file, ct))
{
if (!line.Contains("ClientLog ", StringComparison.OrdinalIgnoreCase))
continue;
var entry = Parse(line);
var key = $"{entry.Level}|{entry.Source}|{entry.Route}|{Normalize(entry.Message)}";
if (!groups.TryGetValue(key, out var group))
{
group = new ClientLogGroupItem
{
Key = key,
Level = entry.Level,
Source = entry.Source,
Route = entry.Route,
Message = entry.Message,
Count = 0,
LastSeen = entry.Timestamp
};
groups[key] = group;
}
group.Count++;
group.LastSeen = entry.Timestamp ?? group.LastSeen;
}
}
await SendAsync(groups.Values
.OrderByDescending(item => item.Count)
.ThenByDescending(item => item.LastSeen)
.Take(10)
.ToList(), cancellation: ct);
}
private static RecentClientLogEntry Parse(string line)
{
return new RecentClientLogEntry
{
Timestamp = ExtractTimestamp(line),
Level = ExtractBetween(line, "] [", "]"),
Source = ExtractTokenAfter(line, "ClientLog ", 1),
Route = ExtractNamedValue(line, "Route="),
Message = ExtractMessage(line)
};
}
private static string Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return "";
return value
.Replace("0x", "0x*", StringComparison.OrdinalIgnoreCase)
.Replace("\"", string.Empty, StringComparison.OrdinalIgnoreCase);
}
private static string? ExtractTimestamp(string line)
{
if (!line.StartsWith("[")) return null;
var end = line.IndexOf("] [", StringComparison.Ordinal);
return end > 1 ? line[1..end] : null;
}
private static string? ExtractBetween(string text, string start, string end)
{
var index = text.IndexOf(start, StringComparison.OrdinalIgnoreCase);
if (index < 0) return null;
var slice = text[(index + start.Length)..];
var endIndex = slice.IndexOf(end, StringComparison.OrdinalIgnoreCase);
return endIndex < 0 ? slice : slice[..endIndex];
}
private static string? ExtractTokenAfter(string text, string start, int tokenIndex)
{
var index = text.IndexOf(start, StringComparison.OrdinalIgnoreCase);
if (index < 0) return null;
var tokens = text[index..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
return tokens.Length > tokenIndex ? tokens[tokenIndex] : null;
}
private static string? ExtractMessage(string line)
{
var stackIndex = line.IndexOf("Stack=", StringComparison.OrdinalIgnoreCase);
if (stackIndex >= 0)
{
return line[..stackIndex].Trim();
}
var clientIndex = line.IndexOf("ClientLog ", StringComparison.OrdinalIgnoreCase);
return clientIndex >= 0 ? line[(clientIndex + "ClientLog ".Length)..] : line;
}
private static string? ExtractNamedValue(string text, string prefix)
{
var index = text.IndexOf(prefix, StringComparison.OrdinalIgnoreCase);
if (index < 0) return null;
var slice = text[(index + prefix.Length)..];
var end = slice.IndexOf(' ');
return end < 0 ? slice : slice[..end];
}
}
@@ -71,18 +71,53 @@ public sealed class GetRecentLogsEndpoint : EndpointWithoutRequest<IReadOnlyList
private static RecentClientLogEntry Parse(string line)
{
var entry = new RecentClientLogEntry();
var messageIndex = line.IndexOf("ClientLog ", StringComparison.OrdinalIgnoreCase);
entry.Message = messageIndex >= 0 ? line[(messageIndex + "ClientLog ".Length)..] : line;
entry.Timestamp = ExtractTimestamp(line);
entry.Level = ExtractBetween(line, "] [", "]");
entry.Source = ExtractTokenAfter(line, "ClientLog ", 1);
entry.Route = ExtractNamedValue(line, "Route=");
entry.Screen = ExtractNamedValue(line, "Screen=");
entry.Feature = ExtractNamedValue(line, "Feature=");
entry.Action = ExtractNamedValue(line, "Action=");
entry.Step = ExtractNamedValue(line, "Step=");
entry.BuildVersion = ExtractNamedValue(line, "BuildVersion=");
entry.Message = ExtractMessage(line);
return entry;
}
var timestampEnd = line.IndexOf("] [", StringComparison.Ordinal);
if (line.StartsWith("[") && timestampEnd > 1)
private static string? ExtractTimestamp(string line)
{
if (!line.StartsWith("[")) return null;
var end = line.IndexOf("] [", StringComparison.Ordinal);
return end > 1 ? line[1..end] : null;
}
private static string? ExtractBetween(string text, string start, string end)
{
var index = text.IndexOf(start, StringComparison.OrdinalIgnoreCase);
if (index < 0) return null;
var slice = text[(index + start.Length)..];
var endIndex = slice.IndexOf(end, StringComparison.OrdinalIgnoreCase);
return endIndex < 0 ? slice : slice[..endIndex];
}
private static string? ExtractTokenAfter(string text, string start, int tokenIndex)
{
var index = text.IndexOf(start, StringComparison.OrdinalIgnoreCase);
if (index < 0) return null;
var tokens = text[index..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
return tokens.Length > tokenIndex ? tokens[tokenIndex] : null;
}
private static string? ExtractMessage(string line)
{
var messageIndex = line.IndexOf("Stack=", StringComparison.OrdinalIgnoreCase);
if (messageIndex >= 0)
{
entry.Timestamp = line[1..timestampEnd];
return line[..messageIndex].Trim();
}
entry.Level = Extract(line, "ClientLog ", " ");
entry.Source = ExtractAfter(line, "ClientLog ", 1);
return entry;
var clientIndex = line.IndexOf("ClientLog ", StringComparison.OrdinalIgnoreCase);
return clientIndex >= 0 ? line[(clientIndex + "ClientLog ".Length)..] : line;
}
private static bool Matches(RecentClientLogEntry entry, string? level, string? source, string? route, string? term)
@@ -115,18 +150,12 @@ public sealed class GetRecentLogsEndpoint : EndpointWithoutRequest<IReadOnlyList
return true;
}
private static string? Extract(string text, string start, string end)
private static string? ExtractNamedValue(string text, string prefix)
{
var index = text.IndexOf(start, StringComparison.OrdinalIgnoreCase);
var index = text.IndexOf(prefix, StringComparison.OrdinalIgnoreCase);
if (index < 0) return null;
var slice = text[(index + start.Length)..];
var endIndex = slice.IndexOf(end, StringComparison.OrdinalIgnoreCase);
return endIndex < 0 ? slice : slice[..endIndex];
}
private static string? ExtractAfter(string text, string start, int tokenIndex)
{
var tokens = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return tokens.Length > tokenIndex ? tokens[tokenIndex] : null;
var slice = text[(index + prefix.Length)..];
var end = slice.IndexOf(' ');
return end < 0 ? slice : slice[..end];
}
}