Skip to content

Commit 2af7157

Browse files
committed
Add customizable archive naming for FileLogWriter (Fixes #2)
1 parent fd6cdd2 commit 2af7157

6 files changed

Lines changed: 242 additions & 7 deletions

File tree

site/docs/file-writer.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,40 @@ Behavior:
5555
- Retention deletes oldest archives beyond `RetainedFileCountLimit`.
5656
- `FlushToDisk = true` forces durable `FileStream.Flush(true)` for crash-critical workloads.
5757

58+
## Custom archive file names
59+
60+
You can customize rolling archive names with `FileLogWriterOptions.ArchiveFileNameFormatter`.
61+
62+
```csharp
63+
var writer = new FileLogWriter(
64+
new FileLogWriterOptions("logs/output.txt")
65+
{
66+
RollingInterval = FileRollingInterval.Daily,
67+
ArchiveFileNameFormatter = FileArchiveFileNameFormatters.DateTime
68+
});
69+
```
70+
71+
This produces names like `output.2026-02-19-19_16_03.txt`.
72+
73+
Built-in helpers:
74+
75+
- `FileArchiveFileNameFormatters.Compact` (default behavior)
76+
- `FileArchiveFileNameFormatters.DateTime`
77+
- `FileArchiveFileNameFormatters.DateTimeWithMilliseconds`
78+
79+
You can also provide your own callback:
80+
81+
```csharp
82+
ArchiveFileNameFormatter = context =>
83+
$"{context.BaseFileName}.{context.Timestamp:yyyy-MM-dd-HH_mm_ss}{context.Extension}";
84+
```
85+
86+
Guidelines:
87+
88+
- Return a file **name**, not a path.
89+
- Keep names in the same directory as the active file.
90+
- Include `context.Sequence` (or allow suffixing) to avoid collisions in multi-process scenarios.
91+
5892
## JSON-lines logging
5993

6094
```csharp

src/XenoAtom.Logging.Tests/FileLogWriterTests.cs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,92 @@ public void FileLogWriter_ArchiveTimestampMode_UsesConfiguredClock()
299299
Assert.IsTrue(localArchive.Contains(localStamp, StringComparison.Ordinal));
300300
}
301301

302+
[TestMethod]
303+
public void FileLogWriter_ArchiveFileNameFormatter_CanUseReadableDateTimeFormat()
304+
{
305+
var fixedUtcTime = new DateTimeOffset(2026, 02, 09, 19, 16, 3, TimeSpan.Zero);
306+
var expectedArchiveFileName = "output.2026-02-09-19_16_03.txt";
307+
var filePath = Path.Combine(_tempDirectory, "output.txt");
308+
309+
var writer = new FileLogWriter(
310+
new FileLogWriterOptions(filePath)
311+
{
312+
AutoFlush = true,
313+
FileSizeLimitBytes = 1,
314+
ArchiveFileNameFormatter = FileArchiveFileNameFormatters.DateTime
315+
});
316+
317+
var config = CreateConfig(writer);
318+
config.TimeProvider = new MutableTimeProvider(fixedUtcTime);
319+
320+
LogManager.Initialize(config);
321+
var logger = LogManager.GetLogger("Tests.File.Archive.Format");
322+
logger.Info("first");
323+
logger.Info("second");
324+
LogManager.Shutdown();
325+
326+
var archives = Directory.GetFiles(_tempDirectory, "output.*.txt", SearchOption.TopDirectoryOnly)
327+
.Select(Path.GetFileName)
328+
.ToArray();
329+
CollectionAssert.Contains(archives, expectedArchiveFileName);
330+
}
331+
332+
[TestMethod]
333+
public void FileLogWriter_ArchiveFileNameFormatter_IgnoresSequence_StillProducesUniqueArchiveName()
334+
{
335+
var fixedUtcTime = new DateTimeOffset(2026, 02, 09, 19, 16, 3, TimeSpan.Zero);
336+
var filePath = Path.Combine(_tempDirectory, "output.txt");
337+
var collidingArchivePath = Path.Combine(_tempDirectory, "output.2026-02-09-19_16_03.txt");
338+
339+
File.WriteAllText(collidingArchivePath, "collision");
340+
341+
var writer = new FileLogWriter(
342+
new FileLogWriterOptions(filePath)
343+
{
344+
AutoFlush = true,
345+
FileSizeLimitBytes = 1,
346+
ArchiveFileNameFormatter = static context => $"{context.BaseFileName}.{context.Timestamp.ToString("yyyy-MM-dd-HH_mm_ss", CultureInfo.InvariantCulture)}{context.Extension}"
347+
});
348+
349+
var config = CreateConfig(writer);
350+
config.TimeProvider = new MutableTimeProvider(fixedUtcTime);
351+
352+
LogManager.Initialize(config);
353+
var logger = LogManager.GetLogger("Tests.File.Archive.Format.SequenceFallback");
354+
logger.Info("first");
355+
logger.Info("second");
356+
LogManager.Shutdown();
357+
358+
Assert.IsTrue(File.Exists(collidingArchivePath), "The pre-existing archive should remain untouched.");
359+
360+
var generatedArchives = Directory.GetFiles(_tempDirectory, "output.*.txt", SearchOption.TopDirectoryOnly)
361+
.Select(Path.GetFileName)
362+
.ToArray();
363+
Assert.IsTrue(generatedArchives.Any(name => string.Equals(name, "output.2026-02-09-19_16_03.1.txt", StringComparison.Ordinal)));
364+
}
365+
366+
[TestMethod]
367+
public void FileLogWriter_ArchiveFileNameFormatter_InvalidPath_Throws()
368+
{
369+
var filePath = Path.Combine(_tempDirectory, "output.txt");
370+
var writer = new FileLogWriter(
371+
new FileLogWriterOptions(filePath)
372+
{
373+
AutoFlush = true,
374+
FileSizeLimitBytes = 1,
375+
ArchiveFileNameFormatter = static _ => "nested/output.txt"
376+
});
377+
378+
LogManager.Initialize(CreateConfig(writer));
379+
var logger = LogManager.GetLogger("Tests.File.Archive.InvalidFormatter");
380+
logger.Info("first");
381+
382+
var exception = Assert.Throws<InvalidOperationException>(() => logger.Info("second"));
383+
Assert.IsNotNull(exception);
384+
StringAssert.Contains(exception.Message, "must return a file name, not a path");
385+
LogManager.Shutdown();
386+
}
387+
302388
[TestMethod]
303389
public void FileLogWriter_FlushToDisk_UsesDurableFlushMode()
304390
{
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright (c) Alexandre Mutel. All rights reserved.
2+
// Licensed under the BSD-Clause 2 license.
3+
// See license.txt file in the project root for full license information.
4+
5+
namespace XenoAtom.Logging.Writers;
6+
7+
/// <summary>
8+
/// Context passed to <see cref="FileLogWriterOptions.ArchiveFileNameFormatter"/> when rolling files.
9+
/// </summary>
10+
/// <param name="BaseFileName">The base file name without extension (for example <c>app</c>).</param>
11+
/// <param name="Extension">The active file extension (for example <c>.log</c>).</param>
12+
/// <param name="Timestamp">The roll timestamp converted according to <see cref="FileLogWriterOptions.ArchiveTimestampMode"/>.</param>
13+
/// <param name="Sequence">The sequence suffix used to disambiguate name collisions. The first attempt uses <c>0</c>.</param>
14+
public readonly record struct FileArchiveFileNameContext(
15+
string BaseFileName,
16+
string Extension,
17+
DateTime Timestamp,
18+
long Sequence);
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright (c) Alexandre Mutel. All rights reserved.
2+
// Licensed under the BSD-Clause 2 license.
3+
// See license.txt file in the project root for full license information.
4+
5+
using System.Globalization;
6+
7+
namespace XenoAtom.Logging.Writers;
8+
9+
/// <summary>
10+
/// Built-in helpers for <see cref="FileLogWriterOptions.ArchiveFileNameFormatter"/>.
11+
/// </summary>
12+
public static class FileArchiveFileNameFormatters
13+
{
14+
/// <summary>
15+
/// Produces the default compact file name format:
16+
/// <c>&lt;base&gt;.&lt;yyyyMMddHHmmssfff&gt;[.&lt;sequence&gt;]&lt;extension&gt;</c>.
17+
/// </summary>
18+
/// <param name="context">The archive naming context.</param>
19+
/// <returns>The archive file name.</returns>
20+
public static string Compact(FileArchiveFileNameContext context)
21+
=> Format(context, "yyyyMMddHHmmssfff");
22+
23+
/// <summary>
24+
/// Produces a human-readable file name format:
25+
/// <c>&lt;base&gt;.&lt;yyyy-MM-dd-HH_mm_ss&gt;[.&lt;sequence&gt;]&lt;extension&gt;</c>.
26+
/// </summary>
27+
/// <param name="context">The archive naming context.</param>
28+
/// <returns>The archive file name.</returns>
29+
public static string DateTime(FileArchiveFileNameContext context)
30+
=> Format(context, "yyyy-MM-dd-HH_mm_ss");
31+
32+
/// <summary>
33+
/// Produces a human-readable file name format with milliseconds:
34+
/// <c>&lt;base&gt;.&lt;yyyy-MM-dd-HH_mm_ss_fff&gt;[.&lt;sequence&gt;]&lt;extension&gt;</c>.
35+
/// </summary>
36+
/// <param name="context">The archive naming context.</param>
37+
/// <returns>The archive file name.</returns>
38+
public static string DateTimeWithMilliseconds(FileArchiveFileNameContext context)
39+
=> Format(context, "yyyy-MM-dd-HH_mm_ss_fff");
40+
41+
private static string Format(in FileArchiveFileNameContext context, string timestampFormat)
42+
{
43+
var stamp = context.Timestamp.ToString(timestampFormat, CultureInfo.InvariantCulture);
44+
if (context.Sequence == 0)
45+
{
46+
return $"{context.BaseFileName}.{stamp}{context.Extension}";
47+
}
48+
49+
return $"{context.BaseFileName}.{stamp}.{context.Sequence.ToString(CultureInfo.InvariantCulture)}{context.Extension}";
50+
}
51+
}

src/XenoAtom.Logging/Writers/FileLogWriter.cs

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public class FileLogWriter : LogWriter
3434
private readonly FileOptions _fileOptions;
3535
private readonly bool _flushToDisk;
3636
private readonly FileArchiveTimestampMode _archiveTimestampMode;
37+
private readonly Func<FileArchiveFileNameContext, string>? _archiveFileNameFormatter;
3738
private readonly FileLogWriterFailureMode _failureMode;
3839
private readonly Action<FileLogWriterFailureContext>? _failureHandler;
3940
private readonly int _retryCount;
@@ -112,6 +113,7 @@ public FileLogWriter(FileLogWriterOptions options)
112113
_fileOptions = options.FileOptions;
113114
_flushToDisk = options.FlushToDisk;
114115
_archiveTimestampMode = options.ArchiveTimestampMode;
116+
_archiveFileNameFormatter = options.ArchiveFileNameFormatter;
115117
_failureMode = options.FailureMode;
116118
_failureHandler = options.FailureHandler;
117119
_retryCount = options.RetryCount;
@@ -389,11 +391,22 @@ private bool TryMoveCurrentFileToArchive(DateTime timestamp)
389391
_ => throw new ArgumentOutOfRangeException(nameof(_archiveTimestampMode), _archiveTimestampMode, null)
390392
};
391393

392-
var stamp = archiveTimestamp.ToString("yyyyMMddHHmmssfff", CultureInfo.InvariantCulture);
393394
var sequence = 0L;
395+
var attemptedFileNames = new HashSet<string>(StringComparer.Ordinal);
394396
while (true)
395397
{
396-
var archivePath = GetArchivePath(stamp, sequence);
398+
var archiveFileName = GetArchiveFileName(archiveTimestamp, sequence);
399+
if (!attemptedFileNames.Add(archiveFileName))
400+
{
401+
archiveFileName = AppendSequenceSuffix(archiveFileName, sequence);
402+
if (!attemptedFileNames.Add(archiveFileName))
403+
{
404+
sequence++;
405+
continue;
406+
}
407+
}
408+
409+
var archivePath = Path.Combine(_directoryPath, archiveFileName);
397410
try
398411
{
399412
File.Move(_filePath, archivePath, overwrite: false);
@@ -412,12 +425,35 @@ private bool TryMoveCurrentFileToArchive(DateTime timestamp)
412425
}
413426
}
414427

415-
private string GetArchivePath(string stamp, long sequence)
428+
private string GetArchiveFileName(DateTime archiveTimestamp, long sequence)
429+
{
430+
var context = new FileArchiveFileNameContext(_archiveFileNamePrefix, _archiveFileExtension, archiveTimestamp, sequence);
431+
var formatter = _archiveFileNameFormatter;
432+
var archiveFileName = formatter is null
433+
? FileArchiveFileNameFormatters.Compact(context)
434+
: formatter(context);
435+
436+
if (string.IsNullOrWhiteSpace(archiveFileName))
437+
{
438+
throw new InvalidOperationException("ArchiveFileNameFormatter returned null, empty, or whitespace.");
439+
}
440+
441+
if (!string.Equals(Path.GetFileName(archiveFileName), archiveFileName, StringComparison.Ordinal))
442+
{
443+
throw new InvalidOperationException("ArchiveFileNameFormatter must return a file name, not a path.");
444+
}
445+
446+
return archiveFileName;
447+
}
448+
449+
private static string AppendSequenceSuffix(string archiveFileName, long sequence)
416450
{
417-
var suffix = sequence == 0
418-
? stamp
419-
: $"{stamp}.{sequence.ToString(CultureInfo.InvariantCulture)}";
420-
return Path.Combine(_directoryPath, $"{_archiveFileNamePrefix}.{suffix}{_archiveFileExtension}");
451+
var extension = Path.GetExtension(archiveFileName);
452+
var extensionLength = extension.Length;
453+
var baseFileName = extensionLength == 0
454+
? archiveFileName
455+
: archiveFileName.Substring(0, archiveFileName.Length - extensionLength);
456+
return $"{baseFileName}.{sequence.ToString(CultureInfo.InvariantCulture)}{extension}";
421457
}
422458

423459
private void ApplyRetentionPolicy()

src/XenoAtom.Logging/Writers/FileLogWriterOptions.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ public FileLogWriterOptions(FileLogWriterOptions options)
5757
FileOptions = options.FileOptions;
5858
FlushToDisk = options.FlushToDisk;
5959
ArchiveTimestampMode = options.ArchiveTimestampMode;
60+
ArchiveFileNameFormatter = options.ArchiveFileNameFormatter;
6061
FailureMode = options.FailureMode;
6162
FailureHandler = options.FailureHandler;
6263
RetryCount = options.RetryCount;
@@ -138,6 +139,15 @@ public FileLogWriterOptions(FileLogWriterOptions options)
138139
/// </summary>
139140
public FileArchiveTimestampMode ArchiveTimestampMode { get; set; } = FileArchiveTimestampMode.Utc;
140141

142+
/// <summary>
143+
/// Gets or sets an optional callback used to build archive file names when rolling.
144+
/// </summary>
145+
/// <remarks>
146+
/// When <see langword="null"/>, the default archive naming format is used:
147+
/// <c>&lt;base&gt;.&lt;yyyyMMddHHmmssfff&gt;[.&lt;sequence&gt;]&lt;extension&gt;</c>.
148+
/// </remarks>
149+
public Func<FileArchiveFileNameContext, string>? ArchiveFileNameFormatter { get; set; }
150+
141151
/// <summary>
142152
/// Gets or sets how write/roll I/O failures are handled.
143153
/// </summary>

0 commit comments

Comments
 (0)