Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions cli/SimpleModule.Cli/Commands/Tail/FileTailSource.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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<string?> 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;
}
}
23 changes: 23 additions & 0 deletions cli/SimpleModule.Cli/Commands/Tail/LogEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Collections.Generic;

namespace SimpleModule.Cli.Commands.Tail;

/// <summary>
/// Normalised representation of a single log line, regardless of its source format
/// (Serilog compact JSON, .NET <c>JsonConsoleFormatter</c>, or arbitrary plain text).
/// </summary>
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<string, string?> Properties { get; init; } =
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
154 changes: 154 additions & 0 deletions cli/SimpleModule.Cli/Commands/Tail/LogEntryFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
namespace SimpleModule.Cli.Commands.Tail;

/// <summary>
/// Stateless predicates that decide whether a <see cref="LogEntry"/> matches a given
/// <see cref="TailSettings"/>.
/// </summary>
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;
}
}
Loading
Loading