TimestampedRollingFileSink.cs 7.8 KB
using System.Text;
using Serilog.Core;
using Serilog.Debugging;
using Serilog.Events;
using Serilog.Formatting.Display;

namespace Rcs.Api.Logging;

public sealed class TimestampedRollingFileSink : ILogEventSink, IDisposable
{
    private readonly object _syncRoot = new();
    private readonly string _logsPath;
    private readonly string _filePrefix;
    private readonly long _fileSizeLimitBytes;
    private readonly int _retentionDays;
    private readonly Encoding _encoding;
    private readonly MessageTemplateTextFormatter _formatter;

    private StreamWriter? _writer;
    private string? _currentFilePath;
    private long _currentFileSizeBytes;
    private bool _disposed;

    public TimestampedRollingFileSink(
        string logsPath,
        string filePrefix,
        long fileSizeLimitBytes,
        int retentionDays,
        string outputTemplate)
    {
        if (string.IsNullOrWhiteSpace(logsPath))
        {
            throw new ArgumentException("logsPath cannot be null or empty.", nameof(logsPath));
        }

        if (string.IsNullOrWhiteSpace(filePrefix))
        {
            throw new ArgumentException("filePrefix cannot be null or empty.", nameof(filePrefix));
        }

        if (fileSizeLimitBytes <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(fileSizeLimitBytes), "fileSizeLimitBytes must be greater than 0.");
        }

        if (retentionDays <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(retentionDays), "retentionDays must be greater than 0.");
        }

        _logsPath = logsPath;
        _filePrefix = filePrefix;
        _fileSizeLimitBytes = fileSizeLimitBytes;
        _retentionDays = retentionDays;
        _encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
        _formatter = new MessageTemplateTextFormatter(outputTemplate, null);

        Directory.CreateDirectory(_logsPath);
        CleanupExpiredFiles();

        if (!TryOpenExistingWritableFile())
        {
            RollFile();
        }
    }

    public void Emit(LogEvent logEvent)
    {
        if (_disposed)
        {
            return;
        }

        lock (_syncRoot)
        {
            try
            {
                if (_writer == null)
                {
                    RollFile();
                }

                using var renderedWriter = new StringWriter();
                _formatter.Format(logEvent, renderedWriter);
                var rendered = renderedWriter.ToString();
                var renderedBytes = _encoding.GetByteCount(rendered);

                // Keep the first file available for startup even if a single event is oversized.
                if (_currentFileSizeBytes > 0 && _currentFileSizeBytes + renderedBytes > _fileSizeLimitBytes)
                {
                    RollFile();
                }

                _writer!.Write(rendered);
                _writer.Flush();
                _currentFileSizeBytes += renderedBytes;
            }
            catch (Exception ex)
            {
                SelfLog.WriteLine("TimestampedRollingFileSink emit failed: {0}", ex);
            }
        }
    }

    public void Dispose()
    {
        if (_disposed)
        {
            return;
        }

        lock (_syncRoot)
        {
            _disposed = true;
            _writer?.Dispose();
            _writer = null;
            _currentFilePath = null;
            _currentFileSizeBytes = 0;
        }
    }

    private void RollFile()
    {
        _writer?.Dispose();
        _writer = null;

        var filePath = CreateUniqueLogFilePath();
        var stream = new FileStream(filePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read);
        _writer = new StreamWriter(stream, _encoding);
        _currentFilePath = filePath;
        _currentFileSizeBytes = 0;

        CleanupExpiredFiles();
    }

    private bool TryOpenExistingWritableFile()
    {
        try
        {
            var candidates = Directory.GetFiles(_logsPath, $"{_filePrefix}-*.log")
                .Select(file => new FileInfo(file))
                .Where(file => file.Length < _fileSizeLimitBytes)
                .OrderByDescending(file => GetSortTimestamp(file));

            foreach (var candidate in candidates)
            {
                try
                {
                    var stream = new FileStream(candidate.FullName, FileMode.Append, FileAccess.Write, FileShare.Read);
                    _writer = new StreamWriter(stream, _encoding);
                    _currentFilePath = candidate.FullName;
                    _currentFileSizeBytes = candidate.Length;
                    return true;
                }
                catch (Exception ex)
                {
                    SelfLog.WriteLine("TimestampedRollingFileSink open existing file failed ({0}): {1}", candidate.FullName, ex);
                }
            }
        }
        catch (Exception ex)
        {
            SelfLog.WriteLine("TimestampedRollingFileSink scan existing files failed: {0}", ex);
        }

        return false;
    }

    private DateTime GetSortTimestamp(FileInfo fileInfo)
    {
        return TryGetLogFileTimestamp(fileInfo.FullName, out var timestamp)
            ? timestamp
            : fileInfo.LastWriteTime;
    }

    private string CreateUniqueLogFilePath()
    {
        var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
        var baseName = $"{_filePrefix}-{timestamp}";
        var filePath = Path.Combine(_logsPath, $"{baseName}.log");

        var sequence = 1;
        while (File.Exists(filePath))
        {
            filePath = Path.Combine(_logsPath, $"{baseName}_{sequence:000}.log");
            sequence++;
        }

        return filePath;
    }

    private void CleanupExpiredFiles()
    {
        try
        {
            var cutoff = DateTime.Now.AddDays(-_retentionDays);
            var files = Directory.GetFiles(_logsPath, $"{_filePrefix}-*.log")
                .ToList();

            foreach (var file in files)
            {
                // Never delete the active file.
                if (string.Equals(file, _currentFilePath, StringComparison.OrdinalIgnoreCase))
                {
                    continue;
                }

                if (TryGetLogFileTimestamp(file, out var fileTimestamp))
                {
                    if (fileTimestamp < cutoff)
                    {
                        File.Delete(file);
                    }
                    continue;
                }

                // Fallback for files that do not match the naming pattern.
                if (File.GetCreationTime(file) < cutoff)
                {
                    File.Delete(file);
                }
            }
        }
        catch (Exception ex)
        {
            SelfLog.WriteLine("TimestampedRollingFileSink cleanup failed: {0}", ex);
        }
    }

    private bool TryGetLogFileTimestamp(string filePath, out DateTime timestamp)
    {
        timestamp = default;
        var fileName = Path.GetFileNameWithoutExtension(filePath);

        if (string.IsNullOrWhiteSpace(fileName))
        {
            return false;
        }

        var prefix = $"{_filePrefix}-";
        if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
        {
            return false;
        }

        var core = fileName[prefix.Length..];
        var underscoreIndex = core.IndexOf('_');
        if (underscoreIndex < 0)
        {
            return false;
        }

        // Strip optional sequence suffix (e.g. _001).
        var secondUnderscore = core.IndexOf('_', underscoreIndex + 1);
        var timeToken = secondUnderscore >= 0 ? core[..secondUnderscore] : core;

        return DateTime.TryParseExact(
            timeToken,
            "yyyyMMdd_HHmmss",
            System.Globalization.CultureInfo.InvariantCulture,
            System.Globalization.DateTimeStyles.None,
            out timestamp);
    }
}