diff --git a/cli/SimpleModule.Cli/Commands/Tail/FileTailSource.cs b/cli/SimpleModule.Cli/Commands/Tail/FileTailSource.cs new file mode 100644 index 00000000..6493ffd7 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Tail/FileTailSource.cs @@ -0,0 +1,130 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text; + +namespace SimpleModule.Cli.Commands.Tail; + +public sealed class FileTailSource : TailSource +{ + private const int PollDelayMs = 250; + + private readonly string _path; + private readonly bool _follow; + + public FileTailSource(string path, bool follow) + { + ArgumentNullException.ThrowIfNull(path); + _path = path; + _follow = follow; + } + + public override string Name => Path.GetFileName(_path); + + public override async IAsyncEnumerable ReadLinesAsync( + [EnumeratorCancellation] CancellationToken cancellationToken + ) + { + await using var stream = new FileStream( + _path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete + ); + + if (_follow) + { + stream.Seek(0, SeekOrigin.End); + } + + using var reader = new StreamReader( + stream, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: true + ); + + var buffer = new StringBuilder(); + while (!cancellationToken.IsCancellationRequested) + { + var line = await ReadLineWithBufferAsync(reader, buffer, cancellationToken) + .ConfigureAwait(false); + + if (line is not null) + { + yield return line; + continue; + } + + if (!_follow) + { + if (buffer.Length > 0) + { + yield return buffer.ToString(); + } + yield break; + } + + try + { + await Task.Delay(PollDelayMs, cancellationToken).ConfigureAwait(false); + } + catch (TaskCanceledException) + { + yield break; + } + } + } + + private static async Task ReadLineWithBufferAsync( + StreamReader reader, + StringBuilder buffer, + CancellationToken cancellationToken + ) + { + var charBuf = new char[1024]; + while (!cancellationToken.IsCancellationRequested) + { + // First, see if buffer already contains a complete line carried over from + // a previous read. We can't just append-then-scan because the previous + // carry-over may itself contain newlines. + if (TryExtractLine(buffer, out var carried)) + { + return carried; + } + + var read = await reader + .ReadAsync(charBuf.AsMemory(0, charBuf.Length), cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + return null; + } + + buffer.Append(charBuf, 0, read); + } + return null; + } + + private static bool TryExtractLine(StringBuilder buffer, out string line) + { + for (var i = 0; i < buffer.Length; i++) + { + if (buffer[i] != '\n') + { + continue; + } + + var end = i; + if (end > 0 && buffer[end - 1] == '\r') + { + end--; + } + + line = buffer.ToString(0, end); + buffer.Remove(0, i + 1); + return true; + } + + line = string.Empty; + return false; + } +} diff --git a/cli/SimpleModule.Cli/Commands/Tail/LogEntry.cs b/cli/SimpleModule.Cli/Commands/Tail/LogEntry.cs new file mode 100644 index 00000000..bda9f490 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Tail/LogEntry.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +namespace SimpleModule.Cli.Commands.Tail; + +/// +/// Normalised representation of a single log line, regardless of its source format +/// (Serilog compact JSON, .NET JsonConsoleFormatter, or arbitrary plain text). +/// +public sealed record LogEntry +{ + public DateTimeOffset? Timestamp { get; init; } + + public string? Level { get; init; } + + public string? Source { get; init; } + + public string? Message { get; init; } + + public string? Raw { get; init; } + + public IReadOnlyDictionary Properties { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); +} diff --git a/cli/SimpleModule.Cli/Commands/Tail/LogEntryFilter.cs b/cli/SimpleModule.Cli/Commands/Tail/LogEntryFilter.cs new file mode 100644 index 00000000..fd8241c0 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Tail/LogEntryFilter.cs @@ -0,0 +1,154 @@ +namespace SimpleModule.Cli.Commands.Tail; + +/// +/// Stateless predicates that decide whether a matches a given +/// . +/// +public static class LogEntryFilter +{ + public static bool Matches(LogEntry entry, TailSettings settings) + { + ArgumentNullException.ThrowIfNull(entry); + ArgumentNullException.ThrowIfNull(settings); + + if (!MatchesLevel(entry, settings.Level)) + { + return false; + } + + if (!MatchesSubstring(entry.Message, settings.Filter)) + { + return false; + } + + if (!MatchesSource(entry.Source, settings.Source)) + { + return false; + } + + if (!MatchesProperty(entry, "UserId", settings.User)) + { + return false; + } + + if (!MatchesProperty(entry, "RequestId", settings.Request)) + { + return false; + } + + return true; + } + + public static int LevelRank(string? level) + { + if (string.IsNullOrWhiteSpace(level)) + { + return -1; + } + + return level.Trim().ToUpperInvariant() switch + { + "TRC" or "TRACE" or "VERBOSE" or "VRB" => 0, + "DBG" or "DEBUG" => 1, + "INF" or "INFO" or "INFORMATION" => 2, + "WRN" or "WARN" or "WARNING" => 3, + "ERR" or "ERROR" => 4, + "CRT" or "CRITICAL" or "FTL" or "FATAL" => 5, + _ => -1, + }; + } + + private static bool MatchesLevel(LogEntry entry, string? minLevel) + { + if (string.IsNullOrWhiteSpace(minLevel)) + { + return true; + } + + var threshold = LevelRank(minLevel); + if (threshold < 0) + { + // Unrecognised level filter — let everything through rather than dropping silently. + return true; + } + + var entryRank = LevelRank(entry.Level); + if (entryRank < 0) + { + // We don't know the entry's level — keep it visible. + return true; + } + + return entryRank >= threshold; + } + + private static bool MatchesSubstring(string? haystack, string? needle) + { + if (string.IsNullOrEmpty(needle)) + { + return true; + } + + if (string.IsNullOrEmpty(haystack)) + { + return false; + } + + return haystack.Contains(needle, StringComparison.OrdinalIgnoreCase); + } + + private static bool MatchesSource(string? entrySource, string? filterSource) + { + if (string.IsNullOrWhiteSpace(filterSource)) + { + return true; + } + + if (string.IsNullOrWhiteSpace(entrySource)) + { + return false; + } + + if (entrySource.Equals(filterSource, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Treat filter as a namespace prefix when separated by '.' + if ( + entrySource.StartsWith(filterSource + ".", StringComparison.OrdinalIgnoreCase) + || entrySource.StartsWith(filterSource, StringComparison.OrdinalIgnoreCase) + ) + { + return true; + } + + return false; + } + + private static bool MatchesProperty(LogEntry entry, string propertyName, string? expected) + { + if (string.IsNullOrWhiteSpace(expected)) + { + return true; + } + + foreach (var kvp in entry.Properties) + { + if (kvp.Key.Equals(propertyName, StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(kvp.Value)) + { + continue; + } + + if (kvp.Value.Equals(expected, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + + return false; + } +} diff --git a/cli/SimpleModule.Cli/Commands/Tail/LogEntryParser.cs b/cli/SimpleModule.Cli/Commands/Tail/LogEntryParser.cs new file mode 100644 index 00000000..097fa94f --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Tail/LogEntryParser.cs @@ -0,0 +1,275 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace SimpleModule.Cli.Commands.Tail; + +/// +/// Parses arbitrary log lines into . Supports Serilog compact JSON, +/// .NET JsonConsoleFormatter, and a best-effort plain-text fallback. +/// +public static class LogEntryParser +{ + private static readonly HashSet TimestampKeys = new(StringComparer.OrdinalIgnoreCase) + { + "@t", + "Timestamp", + "timestamp", + }; + + private static readonly HashSet LevelKeys = new(StringComparer.OrdinalIgnoreCase) + { + "@l", + "Level", + "level", + "LogLevel", + }; + + private static readonly HashSet MessageKeys = new(StringComparer.OrdinalIgnoreCase) + { + "@m", + "Message", + "message", + }; + + private static readonly HashSet MessageTemplateKeys = new( + StringComparer.OrdinalIgnoreCase + ) + { + "@mt", + "MessageTemplate", + }; + + private static readonly HashSet SourceKeys = new(StringComparer.OrdinalIgnoreCase) + { + "SourceContext", + "Category", + "category", + "Logger", + "logger", + }; + + // e.g. "2024-01-02 03:04:05.123 +00:00 [INF] Foo.Bar: hello world" + // or "[12:34:56 INF Foo.Bar] hello world" + // or "2024-01-02T03:04:05.123Z INFO Foo.Bar - hello" + private static readonly Regex PlainTextRegex = new( + """ + ^\s* + (?:\[?(?\d{4}-\d{2}-\d{2}[ Tt]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?(?:\s*[+-]\d{2}:?\d{2}|Z)?)\]? + |\[(?\d{2}:\d{2}:\d{2}(?:[.,]\d+)?)\]) + \s* + \[?(?TRACE|TRC|DEBUG|DBG|INFO|INFORMATION|INF|WARN|WARNING|WRN|ERROR|ERR|CRITICAL|CRT|FATAL|FTL)\]? + \s* + (?:(?[A-Za-z_][\w.]*)\s*[:\-])? + \s*(?.*)$ + """, + RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled + ); + + public static LogEntry Parse(string line) + { + if (string.IsNullOrWhiteSpace(line)) + { + return new LogEntry { Raw = line, Message = line }; + } + + var trimmed = line.TrimStart(); + if (trimmed.StartsWith('{') && TryParseJson(line, out var jsonEntry)) + { + return jsonEntry; + } + + return ParsePlain(line); + } + + public static bool TryParseJson(string line, out LogEntry entry) + { + try + { + using var doc = JsonDocument.Parse(line); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + entry = new LogEntry { Raw = line, Message = line }; + return false; + } + + DateTimeOffset? timestamp = null; + string? level = null; + string? source = null; + string? message = null; + string? messageTemplate = null; + var properties = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (TimestampKeys.Contains(prop.Name)) + { + if ( + prop.Value.ValueKind == JsonValueKind.String + && DateTimeOffset.TryParse( + prop.Value.GetString(), + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, + out var parsed + ) + ) + { + timestamp = parsed; + } + continue; + } + + if (LevelKeys.Contains(prop.Name)) + { + level = ReadString(prop.Value); + continue; + } + + if (SourceKeys.Contains(prop.Name)) + { + source = ReadString(prop.Value); + continue; + } + + if (MessageKeys.Contains(prop.Name)) + { + message = ReadString(prop.Value); + continue; + } + + if (MessageTemplateKeys.Contains(prop.Name)) + { + messageTemplate = ReadString(prop.Value); + continue; + } + + // .NET JsonConsoleFormatter wraps structured properties under "State" + if ( + prop.Name.Equals("State", StringComparison.OrdinalIgnoreCase) + && prop.Value.ValueKind == JsonValueKind.Object + ) + { + foreach (var stateProp in prop.Value.EnumerateObject()) + { + if (stateProp.Name.Equals("Message", StringComparison.OrdinalIgnoreCase)) + { + message ??= ReadString(stateProp.Value); + continue; + } + if ( + stateProp.Name.Equals( + "{OriginalFormat}", + StringComparison.OrdinalIgnoreCase + ) + ) + { + messageTemplate ??= ReadString(stateProp.Value); + continue; + } + properties[stateProp.Name] = ReadString(stateProp.Value); + } + continue; + } + + // EventId object → record EventId.Id / EventId.Name + if ( + prop.Name.Equals("EventId", StringComparison.OrdinalIgnoreCase) + && prop.Value.ValueKind == JsonValueKind.Object + ) + { + foreach (var evtProp in prop.Value.EnumerateObject()) + { + properties["EventId." + evtProp.Name] = ReadString(evtProp.Value); + } + continue; + } + + properties[prop.Name] = ReadString(prop.Value); + } + + entry = new LogEntry + { + Timestamp = timestamp, + Level = level, + Source = source, + Message = message ?? messageTemplate ?? line, + Raw = line, + Properties = properties, + }; + return true; + } + catch (JsonException) + { + entry = new LogEntry { Raw = line, Message = line }; + return false; + } + } + + public static LogEntry ParsePlain(string line) + { + var match = PlainTextRegex.Match(line); + if (!match.Success) + { + return new LogEntry { Raw = line, Message = line }; + } + + DateTimeOffset? timestamp = null; + var tsText = match.Groups["ts"].Value; + if ( + !string.IsNullOrEmpty(tsText) + && DateTimeOffset.TryParse( + tsText, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, + out var parsedTs + ) + ) + { + timestamp = parsedTs; + } + + var lvl = match.Groups["lvl"].Value; + var src = match.Groups["src"].Success ? match.Groups["src"].Value : null; + var msg = match.Groups["msg"].Value; + + return new LogEntry + { + Timestamp = timestamp, + Level = NormaliseLevel(lvl), + Source = string.IsNullOrEmpty(src) ? null : src, + Message = msg, + Raw = line, + }; + } + + private static string? ReadString(JsonElement element) => + element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => null, + _ => element.GetRawText(), + }; + + private static string? NormaliseLevel(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + return raw.ToUpperInvariant() switch + { + "TRC" or "TRACE" => "Trace", + "DBG" or "DEBUG" => "Debug", + "INF" or "INFO" or "INFORMATION" => "Information", + "WRN" or "WARN" or "WARNING" => "Warning", + "ERR" or "ERROR" => "Error", + "CRT" or "CRITICAL" or "FTL" or "FATAL" => "Critical", + _ => raw, + }; + } +} diff --git a/cli/SimpleModule.Cli/Commands/Tail/LogEntryRenderer.cs b/cli/SimpleModule.Cli/Commands/Tail/LogEntryRenderer.cs new file mode 100644 index 00000000..f72b41ba --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Tail/LogEntryRenderer.cs @@ -0,0 +1,156 @@ +using System.Globalization; +using System.Text; +using Spectre.Console; + +namespace SimpleModule.Cli.Commands.Tail; + +/// +/// Renders a parsed to an . +/// +public static class LogEntryRenderer +{ + public static void Render( + IAnsiConsole console, + LogEntry entry, + bool useColor, + string? filePrefix = null + ) + { + ArgumentNullException.ThrowIfNull(console); + ArgumentNullException.ThrowIfNull(entry); + + var line = useColor ? BuildMarkup(entry, filePrefix) : BuildPlain(entry, filePrefix); + if (useColor) + { + console.MarkupLine(line); + } + else + { + console.WriteLine(line); + } + } + + private static string BuildMarkup(LogEntry entry, string? filePrefix) + { + var sb = new StringBuilder(); + + if (!string.IsNullOrEmpty(filePrefix)) + { + sb.Append("[grey]").Append(Markup.Escape($"[{filePrefix}]")).Append("[/] "); + } + + if (entry.Timestamp.HasValue) + { + var ts = entry + .Timestamp.Value.ToLocalTime() + .ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture); + sb.Append("[grey]").Append(Markup.Escape(ts)).Append("[/] "); + } + + var lvl = entry.Level ?? "Information"; + var lvlColor = ColorForLevel(lvl); + var lvlShort = ShortLevel(lvl); + sb.Append('[').Append(lvlColor).Append(']').Append(Markup.Escape(lvlShort)).Append("[/] "); + + if (!string.IsNullOrEmpty(entry.Source)) + { + sb.Append("[italic grey]").Append(Markup.Escape(entry.Source)).Append("[/] "); + } + + sb.Append(Markup.Escape(entry.Message ?? string.Empty)); + + if (entry.Properties.Count > 0) + { + var rendered = RenderProperties(entry); + if (!string.IsNullOrEmpty(rendered)) + { + sb.Append(" [dim]").Append(Markup.Escape(rendered)).Append("[/]"); + } + } + + return sb.ToString(); + } + + private static string BuildPlain(LogEntry entry, string? filePrefix) + { + var sb = new StringBuilder(); + + if (!string.IsNullOrEmpty(filePrefix)) + { + sb.Append('[').Append(filePrefix).Append("] "); + } + + if (entry.Timestamp.HasValue) + { + sb.Append( + entry + .Timestamp.Value.ToLocalTime() + .ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture) + ) + .Append(' '); + } + + sb.Append(ShortLevel(entry.Level ?? "Information")).Append(' '); + + if (!string.IsNullOrEmpty(entry.Source)) + { + sb.Append(entry.Source).Append(' '); + } + + sb.Append(entry.Message ?? string.Empty); + + if (entry.Properties.Count > 0) + { + var rendered = RenderProperties(entry); + if (!string.IsNullOrEmpty(rendered)) + { + sb.Append(' ').Append(rendered); + } + } + + return sb.ToString(); + } + + private static string RenderProperties(LogEntry entry) + { + var parts = new List(entry.Properties.Count); + foreach (var kvp in entry.Properties) + { + if (string.IsNullOrEmpty(kvp.Value)) + { + continue; + } + parts.Add(kvp.Key + "=" + kvp.Value); + } + if (parts.Count == 0) + { + return string.Empty; + } + return "{ " + string.Join(", ", parts) + " }"; + } + + private static string ColorForLevel(string level) => + level.Trim().ToUpperInvariant() switch + { + "TRC" or "TRACE" => "grey50", + "DBG" or "DEBUG" => "grey", + "INF" or "INFO" or "INFORMATION" => "cyan", + "WRN" or "WARN" or "WARNING" => "yellow", + "ERR" or "ERROR" => "red", + "CRT" or "CRITICAL" or "FTL" or "FATAL" => "red bold", + _ => "white", + }; + + private static string ShortLevel(string level) => + level.Trim().ToUpperInvariant() switch + { + "TRC" or "TRACE" => "TRC", + "DBG" or "DEBUG" => "DBG", + "INF" or "INFO" or "INFORMATION" => "INF", + "WRN" or "WARN" or "WARNING" => "WRN", + "ERR" or "ERROR" => "ERR", + "CRT" or "CRITICAL" => "CRT", + "FTL" or "FATAL" => "FTL", + _ => level.Length >= 3 ? level[..3].ToUpperInvariant() : level.ToUpperInvariant(), + }; +} diff --git a/cli/SimpleModule.Cli/Commands/Tail/StdinTailSource.cs b/cli/SimpleModule.Cli/Commands/Tail/StdinTailSource.cs new file mode 100644 index 00000000..d81f3a74 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Tail/StdinTailSource.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace SimpleModule.Cli.Commands.Tail; + +public sealed class StdinTailSource : TailSource +{ + public override string Name => "stdin"; + + public override async IAsyncEnumerable ReadLinesAsync( + [EnumeratorCancellation] CancellationToken cancellationToken + ) + { + var reader = Console.In; + while (!cancellationToken.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); + if (line is null) + { + yield break; + } + yield return line; + } + } +} diff --git a/cli/SimpleModule.Cli/Commands/Tail/TailCommand.cs b/cli/SimpleModule.Cli/Commands/Tail/TailCommand.cs new file mode 100644 index 00000000..f57a2286 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Tail/TailCommand.cs @@ -0,0 +1,252 @@ +using System.Threading.Channels; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Tail; + +public sealed class TailCommand : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, TailSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + + var useColor = ResolveUseColor(settings); + var console = CreateConsole(useColor); + + var sources = BuildSources(settings); + if (sources.Count == 0) + { + console.MarkupLine( + "[red]No log source available — pipe data into stdin or pass --file .[/]" + ); + return 1; + } + + var includePrefix = sources.Count > 1; + + using var cts = new CancellationTokenSource(); + ConsoleCancelEventHandler cancelHandler = (_, e) => + { + e.Cancel = true; + cts.Cancel(); + }; + Console.CancelKeyPress += cancelHandler; + + try + { + var channel = Channel.CreateUnbounded<(string Line, string Source)>( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false } + ); + + var producers = sources + .Select(source => RunProducerAsync(source, channel.Writer, cts.Token)) + .ToList(); + + var completion = Task.WhenAll(producers) + .ContinueWith(_ => channel.Writer.TryComplete(), TaskScheduler.Default); + + await ConsumeAsync( + channel.Reader, + console, + settings, + useColor, + includePrefix, + cts.Token + ) + .ConfigureAwait(false); + + await completion.ConfigureAwait(false); + + return cts.IsCancellationRequested ? 0 : 0; + } + finally + { + Console.CancelKeyPress -= cancelHandler; + } + } + + private static List BuildSources(TailSettings settings) + { + var sources = new List(); + if (settings.Files is { Length: > 0 } files) + { + foreach (var file in files) + { + if (string.IsNullOrWhiteSpace(file)) + { + continue; + } + + if (!File.Exists(file)) + { + AnsiConsole.MarkupLine( + $"[yellow]Skipping missing file:[/] {Markup.Escape(file)}" + ); + continue; + } + + sources.Add(new FileTailSource(file, follow: !settings.NoFollow)); + } + } + else + { + sources.Add(new StdinTailSource()); + } + + return sources; + } + + private static async Task RunProducerAsync( + TailSource source, + ChannelWriter<(string Line, string Source)> writer, + CancellationToken cancellationToken + ) + { + try + { + await foreach ( + var line in source.ReadLinesAsync(cancellationToken).ConfigureAwait(false) + ) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + await writer + .WriteAsync((line, source.Name), cancellationToken) + .ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Expected on Ctrl+C + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 + { + AnsiConsole.MarkupLine( + $"[red][[{Markup.Escape(source.Name)}]][/] {Markup.Escape(ex.Message)}" + ); + } + } + + private static async Task ConsumeAsync( + ChannelReader<(string Line, string Source)> reader, + IAnsiConsole console, + TailSettings settings, + bool useColor, + bool includePrefix, + CancellationToken cancellationToken + ) + { + try + { + await foreach ( + var (line, sourceName) in reader + .ReadAllAsync(cancellationToken) + .ConfigureAwait(false) + ) + { + ProcessLine(console, settings, useColor, includePrefix, line, sourceName); + } + } + catch (OperationCanceledException) + { + // Drain whatever is already in the channel so users see the last few lines + while (reader.TryRead(out var item)) + { + ProcessLine(console, settings, useColor, includePrefix, item.Line, item.Source); + } + } + } + + private static void ProcessLine( + IAnsiConsole console, + TailSettings settings, + bool useColor, + bool includePrefix, + string line, + string sourceName + ) + { + if (settings.Json) + { + // Raw passthrough; skip non-JSON lines. + var trimmed = line.TrimStart(); + if (!trimmed.StartsWith('{')) + { + return; + } + + if (!LogEntryParser.TryParseJson(line, out var jsonEntry)) + { + return; + } + + if (!LogEntryFilter.Matches(jsonEntry, settings)) + { + return; + } + + var prefix = includePrefix ? "[" + sourceName + "] " : string.Empty; + console.WriteLine(prefix + line); + return; + } + + var entry = LogEntryParser.Parse(line); + if (!LogEntryFilter.Matches(entry, settings)) + { + return; + } + + LogEntryRenderer.Render(console, entry, useColor, includePrefix ? sourceName : null); + } + + private static bool ResolveUseColor(TailSettings settings) + { + if (settings.NoColor) + { + return false; + } + + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("NO_COLOR"))) + { + return false; + } + + try + { + if (Console.IsOutputRedirected) + { + return false; + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch +#pragma warning restore CA1031 + { + // Treat probing failures as "not a tty" + return false; + } + + return true; + } + + private static IAnsiConsole CreateConsole(bool useColor) + { + if (useColor) + { + return AnsiConsole.Console; + } + + return AnsiConsole.Create( + new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(Console.Out), + } + ); + } +} diff --git a/cli/SimpleModule.Cli/Commands/Tail/TailSettings.cs b/cli/SimpleModule.Cli/Commands/Tail/TailSettings.cs new file mode 100644 index 00000000..74e43272 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Tail/TailSettings.cs @@ -0,0 +1,51 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Tail; + +public sealed class TailSettings : CommandSettings +{ + [CommandOption("-l|--level")] + [Description( + "Minimum log level to display (Trace, Debug, Information, Warning, Error, Critical). Short forms info/warn/err accepted." + )] + public string? Level { get; set; } + + [CommandOption("-f|--filter")] + [Description("Substring match against the rendered message")] + public string? Filter { get; set; } + + [CommandOption("-s|--source")] + [Description("Logger category (equals or starts-with match)")] + public string? Source { get; set; } + + [CommandOption("-u|--user")] + [Description("Match against the UserId property")] + public string? User { get; set; } + + [CommandOption("-r|--request")] + [Description("Match against the RequestId property")] + public string? Request { get; set; } + + [CommandOption("--json")] + [Description("Pass through raw JSON lines without coloring; skip plain-text lines")] + public bool Json { get; set; } + + [CommandOption("--no-follow")] + [Description("Read once and exit (defaults to follow mode)")] + public bool NoFollow { get; set; } + + [CommandOption("--file ")] + [Description( + "Read from a file instead of stdin. Repeatable; multiple files are tailed concurrently and prefixed with [filename]" + )] +#pragma warning disable CA1819 // Spectre.Console.Cli binds repeatable options to arrays + public string[]? Files { get; set; } +#pragma warning restore CA1819 + + [CommandOption("--no-color")] + [Description( + "Disable colored output. Also auto-disabled when NO_COLOR is set or output is redirected" + )] + public bool NoColor { get; set; } +} diff --git a/cli/SimpleModule.Cli/Commands/Tail/TailSource.cs b/cli/SimpleModule.Cli/Commands/Tail/TailSource.cs new file mode 100644 index 00000000..fa1d5c55 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Tail/TailSource.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace SimpleModule.Cli.Commands.Tail; + +/// +/// Abstract source of log lines. Implementations produce strings until the underlying +/// stream is exhausted or cancellation is requested. +/// +public abstract class TailSource +{ + /// + /// A human-readable name for the source. Used as the file prefix when multiple + /// sources are tailed concurrently. + /// + public abstract string Name { get; } + + public abstract IAsyncEnumerable ReadLinesAsync(CancellationToken cancellationToken); +} diff --git a/cli/SimpleModule.Cli/Program.cs b/cli/SimpleModule.Cli/Program.cs index 9440fba8..9c810ac6 100644 --- a/cli/SimpleModule.Cli/Program.cs +++ b/cli/SimpleModule.Cli/Program.cs @@ -4,6 +4,7 @@ using SimpleModule.Cli.Commands.List; using SimpleModule.Cli.Commands.New; using SimpleModule.Cli.Commands.Skill; +using SimpleModule.Cli.Commands.Tail; using SimpleModule.Cli.Commands.Version; using Spectre.Console.Cli; @@ -22,6 +23,8 @@ config.AddExample("doctor", "--fix"); config.AddExample("skill", "add", "shadcn", "--source", "shadcn/ui/skills/shadcn"); config.AddExample("skill", "update"); + config.AddExample("tail", "--level", "Warning"); + config.AddExample("tail", "--file", "logs/app.log", "--filter", "checkout"); config.AddBranch( "new", @@ -84,6 +87,10 @@ } ); + config + .AddCommand("tail") + .WithDescription("Tail and pretty-print logs from stdin or files"); + config.AddCommand("version").WithDescription("Print the sm CLI version"); }); diff --git a/docs/sm-tail.md b/docs/sm-tail.md new file mode 100644 index 00000000..e218d321 --- /dev/null +++ b/docs/sm-tail.md @@ -0,0 +1,133 @@ +# sm tail + +A Laravel Pail-equivalent log viewer for SimpleModule projects. `sm tail` reads +log lines from standard input or one or more files, pretty-prints them with +colour, and lets you slice the stream with simple filters. + +## Overview + +The SimpleModule framework does not ship a Serilog configuration. Most apps +write structured logs via `Microsoft.Extensions.Logging` to the console +(possibly with `AddJsonConsole`) or to a file. `sm tail` is designed around the +two formats you are most likely to encounter: + +- **JSON-per-line** — Serilog compact JSON (`@t`, `@l`, `@m`, `@mt`, ...) or + the built-in .NET `JsonConsoleFormatter` shape (`Timestamp`, `LogLevel`, + `Category`, `Message`, `State`, ...). +- **Plain text** — best-effort regex extraction of timestamp, level, source, + and message; lines that do not match the regex are still rendered, just with + the entire line treated as the message. + +Each input line is parsed independently. A line that starts with `{` is fed +to the JSON parser; anything else falls through to the plain-text parser. + +## Usage + +Pipe a running app into the viewer: + +```bash +dotnet run --project template/SimpleModule.Host | sm tail +``` + +Tail one or more files (`--file` is repeatable): + +```bash +sm tail --file logs/app.log +sm tail --file logs/app.log --file logs/jobs.log +``` + +When multiple files are passed, each output line is prefixed with `[filename]` +so you can tell streams apart. + +One-shot read of an existing file (no follow): + +```bash +sm tail --file logs/app.log --no-follow +``` + +Combine filters — level + substring + source: + +```bash +sm tail --level Warning --filter checkout --source MyApp.Orders +``` + +Filter by structured properties (UserId, RequestId): + +```bash +sm tail --user 42 +sm tail --request 0HMVABCDEF +``` + +Pass-through raw JSON (useful for piping into `jq` or another tool): + +```bash +sm tail --json --level Error | jq . +``` + +## Flag reference + +| Flag | Short | Description | +| ---------------- | ----- | ------------------------------------------------------------------------------------------------------------ | +| `--level` | `-l` | Minimum level. Accepts `Trace`/`Debug`/`Information`/`Warning`/`Error`/`Critical` and short forms (`info`, `warn`, `err`). Case-insensitive. | +| `--filter` | `-f` | Substring match against the rendered message. Case-insensitive. | +| `--source` | `-s` | Logger category. Equals match or namespace prefix (`Foo.Bar` matches `Foo.Bar.Baz`). | +| `--user` | `-u` | Match the `UserId` property (case-insensitive key lookup). | +| `--request` | `-r` | Match the `RequestId` property. | +| `--json` | | Pass through raw JSON lines without coloring; skip plain-text lines. Useful for piping into other tools. | +| `--no-follow` | | Read once and exit. Default is follow mode (tail forever, polling for appended bytes). | +| `--file ` | | Read from a file instead of stdin. Repeatable. | +| `--no-color` | | Disable colored output. Auto-disabled when `NO_COLOR` is set or stdout is redirected. | + +## JSON vs plain-text auto-detection + +Detection happens per line by checking whether the trimmed line starts with +`{`. This means a single file or stream can mix JSON and plain-text lines +freely — boot-time stack traces, Kestrel banners, and structured logs all +render correctly in the same session. + +Recognised JSON keys (case-insensitive): + +- **Timestamp**: `@t`, `Timestamp`, `timestamp` +- **Level**: `@l`, `Level`, `level`, `LogLevel` +- **Message**: `@m`, `Message`, `message` (falls back to `@mt` / + `MessageTemplate` if no rendered message is present) +- **Source / category**: `SourceContext`, `Category`, `Logger` +- **State**: when present and a JSON object (the `JsonConsoleFormatter` shape), + its keys are flattened into the properties dictionary; `Message` and + `{OriginalFormat}` inside `State` populate the message / message template. +- **EventId**: when present as an object, exposed as `EventId.Id` / + `EventId.Name` properties. + +Anything else in the JSON object becomes a structured property visible to the +`--user`, `--request`, and (in JSON pass-through mode) the rendered tail. + +## Output + +Coloured rendering uses Spectre.Console: + +- Timestamp — grey, local time, `HH:mm:ss.fff` +- Level — three-letter shorthand (`TRC`/`DBG`/`INF`/`WRN`/`ERR`/`CRT`/`FTL`), + red for errors, yellow for warnings, cyan for information, grey for + debug/trace. +- Source — italic grey +- Message — default colour +- Properties — dim, rendered as `{ key=value, ... }` + +`--no-color`, the `NO_COLOR` environment variable, and a redirected stdout all +disable colouring (and skip the markup entirely so piped output is clean). + +## Cancellation + +`Ctrl+C` cancels follow mode and drains any lines already buffered in memory +before exiting. The exit code is `0`. + +## Future work + +- **Merged multi-file tail by timestamp.** When tailing multiple sources from + the SimpleModule AppHost orchestrator (`sm dev`), it would be useful to + merge streams by timestamp rather than printing as they arrive. This is not + yet implemented; multiple `--file` flags currently produce interleaved + output ordered by arrival time only. Each line is still prefixed with its + filename so streams remain distinguishable. +- **Tail HTTP / SignalR streams.** A future addition could subscribe to a + framework-supplied debug stream rather than scraping a local file. diff --git a/tests/SimpleModule.Cli.Tests/TailCommandTests.cs b/tests/SimpleModule.Cli.Tests/TailCommandTests.cs new file mode 100644 index 00000000..83c8a597 --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/TailCommandTests.cs @@ -0,0 +1,220 @@ +using FluentAssertions; +using SimpleModule.Cli.Commands.Tail; + +namespace SimpleModule.Cli.Tests; + +public sealed class TailCommandTests +{ + [Fact] + public void TryParseJson_SerilogCompactLine_ExtractsCoreFields() + { + var line = + """{"@t":"2024-01-02T03:04:05.123Z","@l":"Warning","@mt":"Hello {Name}","Name":"world","SourceContext":"My.App"}"""; + + var parsed = LogEntryParser.TryParseJson(line, out var entry); + + parsed.Should().BeTrue(); + entry.Timestamp.Should().NotBeNull(); + entry.Level.Should().Be("Warning"); + entry.Source.Should().Be("My.App"); + entry.Message.Should().Be("Hello {Name}"); + entry.Properties.Should().ContainKey("Name"); + entry.Properties["Name"].Should().Be("world"); + } + + [Fact] + public void TryParseJson_DotNetJsonConsoleFormatter_ExtractsCategoryStateAndMessage() + { + var line = + """{"Timestamp":"2024-01-02T03:04:05.6789012+00:00","EventId":{"Id":42,"Name":"OrderCreated"},"LogLevel":"Information","Category":"My.App.OrdersController","Message":"Order 7 created","State":{"Message":"Order 7 created","OrderId":7,"{OriginalFormat}":"Order {OrderId} created"}}"""; + + var parsed = LogEntryParser.TryParseJson(line, out var entry); + + parsed.Should().BeTrue(); + entry.Level.Should().Be("Information"); + entry.Source.Should().Be("My.App.OrdersController"); + entry.Message.Should().Be("Order 7 created"); + entry.Properties.Should().ContainKey("OrderId"); + entry.Properties["OrderId"].Should().Be("7"); + entry.Properties.Should().ContainKey("EventId.Id"); + entry.Properties["EventId.Id"].Should().Be("42"); + } + + [Fact] + public void TryParseJson_InvalidJson_ReturnsFalseWithRawMessage() + { + const string line = "{ not really json"; + + var parsed = LogEntryParser.TryParseJson(line, out var entry); + + parsed.Should().BeFalse(); + entry.Message.Should().Be(line); + } + + [Fact] + public void ParsePlain_TimestampLevelSourceMessage_ExtractsParts() + { + const string line = "2024-01-02 03:04:05.123 [INF] Foo.Bar: hello world"; + + var entry = LogEntryParser.ParsePlain(line); + + entry.Timestamp.Should().NotBeNull(); + entry.Level.Should().Be("Information"); + entry.Source.Should().Be("Foo.Bar"); + entry.Message.Should().Be("hello world"); + } + + [Fact] + public void Parse_GarbageLine_ReturnsEntryWithRawMessage() + { + const string line = "this is not a structured log line"; + + var entry = LogEntryParser.Parse(line); + + entry.Message.Should().Be(line); + entry.Level.Should().BeNull(); + entry.Source.Should().BeNull(); + } + + [Fact] + public void Parse_RoutesJsonInputThroughJsonParser() + { + var line = """{"@l":"Error","@m":"boom"}"""; + + var entry = LogEntryParser.Parse(line); + + entry.Level.Should().Be("Error"); + entry.Message.Should().Be("boom"); + } + + [Fact] + public void Matches_LevelWarning_RejectsInformationLines() + { + var settings = new TailSettings { Level = "Warning" }; + var entry = new LogEntry { Level = "Information", Message = "ok" }; + + LogEntryFilter.Matches(entry, settings).Should().BeFalse(); + } + + [Fact] + public void Matches_LevelWarning_AcceptsWarningAndError() + { + var settings = new TailSettings { Level = "Warning" }; + + LogEntryFilter + .Matches(new LogEntry { Level = "Warning", Message = "w" }, settings) + .Should() + .BeTrue(); + + LogEntryFilter + .Matches(new LogEntry { Level = "Error", Message = "e" }, settings) + .Should() + .BeTrue(); + } + + [Fact] + public void Matches_LevelShortForms_AreRecognised() + { + var settings = new TailSettings { Level = "warn" }; + + LogEntryFilter + .Matches(new LogEntry { Level = "Error", Message = "e" }, settings) + .Should() + .BeTrue(); + + LogEntryFilter + .Matches(new LogEntry { Level = "Information", Message = "i" }, settings) + .Should() + .BeFalse(); + } + + [Fact] + public void Matches_SourcePrefix_MatchesChildCategory() + { + var settings = new TailSettings { Source = "Foo.Bar" }; + var entry = new LogEntry { Source = "Foo.Bar.Baz", Message = "x" }; + + LogEntryFilter.Matches(entry, settings).Should().BeTrue(); + } + + [Fact] + public void Matches_SourceMismatch_Rejects() + { + var settings = new TailSettings { Source = "Foo.Bar" }; + var entry = new LogEntry { Source = "Other.Namespace", Message = "x" }; + + LogEntryFilter.Matches(entry, settings).Should().BeFalse(); + } + + [Fact] + public void Matches_UserId_MatchesPropertyValue() + { + var settings = new TailSettings { User = "42" }; + var props = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["UserId"] = "42", + }; + var entry = new LogEntry { Message = "x", Properties = props }; + + LogEntryFilter.Matches(entry, settings).Should().BeTrue(); + } + + [Fact] + public void Matches_UserId_RejectsDifferentValue() + { + var settings = new TailSettings { User = "42" }; + var props = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["UserId"] = "99", + }; + var entry = new LogEntry { Message = "x", Properties = props }; + + LogEntryFilter.Matches(entry, settings).Should().BeFalse(); + } + + [Fact] + public void Matches_RequestId_MatchesPropertyCaseInsensitive() + { + var settings = new TailSettings { Request = "ABCD-1234" }; + var props = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["requestid"] = "abcd-1234", + }; + var entry = new LogEntry { Message = "x", Properties = props }; + + LogEntryFilter.Matches(entry, settings).Should().BeTrue(); + } + + [Fact] + public void Matches_FilterSubstring_MatchesInsideMessage() + { + var settings = new TailSettings { Filter = "checkout" }; + var entry = new LogEntry { Message = "User started checkout flow" }; + + LogEntryFilter.Matches(entry, settings).Should().BeTrue(); + } + + [Fact] + public void Matches_FilterSubstring_RejectsWhenAbsent() + { + var settings = new TailSettings { Filter = "checkout" }; + var entry = new LogEntry { Message = "User signed in" }; + + LogEntryFilter.Matches(entry, settings).Should().BeFalse(); + } + + [Fact] + public void Matches_NoFiltersConfigured_AcceptsEverything() + { + var settings = new TailSettings(); + var entry = new LogEntry { Message = "anything" }; + + LogEntryFilter.Matches(entry, settings).Should().BeTrue(); + } + + [Fact] + public void LevelRank_UnknownLevel_ReturnsNegative() + { + LogEntryFilter.LevelRank("Bogus").Should().BeLessThan(0); + } +}