• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dapplo / Dapplo.Log / 20853969547

09 Jan 2026 01:48PM UTC coverage: 77.174% (-0.7%) from 77.826%
20853969547

push

github

Lakritzator
The snupkg files were empty, with this changes they seem full.

108 of 152 branches covered (71.05%)

355 of 460 relevant lines covered (77.17%)

857816.59 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

81.77
/src/Dapplo.Log.LogFile/FileLogger.cs
1
// Copyright (c) Dapplo and contributors. All rights reserved.
2
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3

4
using System;
5
using System.Collections.Concurrent;
6
using System.Collections.Generic;
7
using System.Diagnostics;
8
using System.IO;
9
using System.IO.Compression;
10
using System.Linq;
11
using System.Text;
12
using System.Threading;
13
using System.Threading.Tasks;
14

15
namespace Dapplo.Log.LogFile
16
{
17
    /// <summary>
18
    ///     This implements a logger which writes to a log file in the background
19
    ///     Filename and directory are configurable, also rolling filename and compression can be activated
20
    /// </summary>
21
    public class FileLogger : AbstractLogger, IDisposable, IFileLoggerConfiguration
22
    {
23
        private static readonly LogSource Log = new LogSource();
1✔
24

25
        /// <summary>
26
        ///     This take care of specifying a logger, to prevent the internal logsource to write to it's own file.
27
        ///     The code should still work if the mapping was already available before (which only works if the registration is
28
        ///     done by name)
29
        /// </summary>
30
        static FileLogger()
31
        {
32
            // Make sure this class doesn't log into it's own file
33
            Log.LogTo(new NullLogger());
1✔
34
        }
1✔
35

36
        private static readonly byte[] Bom = Encoding.UTF8.GetPreamble();
1✔
37
        private readonly UTF8Encoding _bomFreeUtf8Encoding = new UTF8Encoding(false);
2✔
38
        private readonly ConcurrentQueue<Tuple<LogInfo, string, object[]>> _logItems = new ConcurrentQueue<Tuple<LogInfo, string, object[]>>();
2✔
39
        private readonly CancellationTokenSource _backgroundCancellationTokenSource = new CancellationTokenSource();
2✔
40
        private string _previousFilePath;
41
        private Dictionary<string, object> _previousVariables;
42
        private readonly Task<bool> _backgroundTask;
43
        private readonly List<Task> _archiveTaskList = new List<Task>();
2✔
44

45
        /// <summary>
46
        ///     default Constructor which starts the background task
47
        /// </summary>
48
        public FileLogger()
2✔
49
        {
50
            // Start the processing in the background
51
            _backgroundTask = Task.Run(async () => await BackgroundAsync(_backgroundCancellationTokenSource.Token).ConfigureAwait(false));
4✔
52

53
            SetProcessname(this);
2✔
54
        }
2✔
55

56
        private static void SetProcessname(IFileLoggerConfiguration fileLoggerConfiguration)
57
        {
58
            if (fileLoggerConfiguration == null)
3!
59
            {
60
                return;
×
61
            }
62
            using var process = Process.GetCurrentProcess();
3✔
63
            fileLoggerConfiguration.Processname = Path.GetFileNameWithoutExtension(process.MainModule.FileName);
3✔
64
        }
6✔
65

66
        /// <summary>
67
        ///     Configure this logger
68
        /// </summary>
69
        /// <param name="loggerConfiguration"></param>
70
        public override void Configure(ILoggerConfiguration loggerConfiguration)
71
        {
72
            // Copy all values from the ILoggerConfiguration
73
            base.Configure(loggerConfiguration);
1✔
74

75
            // Test if it's a IFileLoggerConfiguration
76
            if (!(loggerConfiguration is IFileLoggerConfiguration fileLoggerConfiguration))
1!
77
            {
78
                return;
×
79
            }
80

81
            // Copy all values from the IFileLoggerConfiguration
82
            if (string.IsNullOrEmpty(fileLoggerConfiguration.Processname))
1✔
83
            {
84
#if !_PCL_
85
                SetProcessname(fileLoggerConfiguration);
1✔
86
#else
87
                                throw new ArgumentNullException(nameof(fileLoggerConfiguration.Processname));
88
#endif
89
            }
90

91
            ArchiveHistory = fileLoggerConfiguration.ArchiveHistory;
1✔
92
            ArchiveCompress = fileLoggerConfiguration.ArchiveCompress;
1✔
93
            ArchiveCount = fileLoggerConfiguration.ArchiveCount;
1✔
94
            ArchiveDirectoryPath = fileLoggerConfiguration.ArchiveDirectoryPath;
1✔
95
            ArchiveExtension = fileLoggerConfiguration.ArchiveExtension;
1✔
96
            ArchiveFilenamePattern = fileLoggerConfiguration.ArchiveFilenamePattern;
1✔
97
            DirectoryPath = fileLoggerConfiguration.DirectoryPath;
1✔
98
            Extension = fileLoggerConfiguration.Extension;
1✔
99
            FilenamePattern = fileLoggerConfiguration.FilenamePattern;
1✔
100
            MaxBufferSize = fileLoggerConfiguration.MaxBufferSize;
1✔
101
            PreFormat = fileLoggerConfiguration.PreFormat;
1✔
102
            Processname = fileLoggerConfiguration.Processname;
1✔
103
            WriteInterval = fileLoggerConfiguration.WriteInterval;
1✔
104
        }
1✔
105

106
        /// <summary>
107
        ///     Setting this to true will format the message in the context of the write call.
108
        ///     If this is set to false, the default, the formatting is done when writing to the file.
109
        ///     First makes the call slower, last could introduce problems with UI owned objects.
110
        /// </summary>
111
        public bool PreFormat { get; set; }
97✔
112

113
        /// <summary>
114
        ///     Limit the internal StringBuilder size,
115
        /// </summary>
116
        public int MaxBufferSize { get; set; } = 512 * 1024;
51✔
117

118
        /// <summary>
119
        ///     Specify how long the background task can wait until it starts writing log entries
120
        /// </summary>
121
        public int WriteInterval { get; set; } = (int) TimeSpan.FromMilliseconds(500).TotalMilliseconds;
56,368,339✔
122

123
        /// <summary>
124
        ///     Name of the application, if null it will be created
125
        /// </summary>
126
        public string Processname { get; set; }
5✔
127

128
        /// <summary>
129
        ///     The extension of log file, default this is ".log"
130
        /// </summary>
131
        public string Extension { get; set; } = ".log";
5✔
132

133
        /// <summary>
134
        ///     Change the format for the filename, as soon as the filename changes, the previous is archived.
135
        /// </summary>
136
        public string FilenamePattern { get; set; } = "{Processname}-{Timestamp:yyyyMMdd}{Extension}";
7✔
137

138
        /// <summary>
139
        ///     Change the format for the filename, the possible arguments are documented in the .
140
        ///     Environment variablen are also expanded.
141
        /// </summary>
142
#if _PCL_
143
        public string DirectoryPath { get; set; } = string.Empty;
144
#else
145
        public string DirectoryPath { get; set; } = @"%LOCALAPPDATA%\{Processname}";
5✔
146
#endif
147

148
        /// <summary>
149
        ///     Change the format for the archived filename
150
        /// </summary>
151
        public string ArchiveFilenamePattern { get; set; } = "{Processname}-{Timestamp:yyyyMMdd}{Extension}";
5✔
152

153
        /// <summary>
154
        ///     The path of the archived file
155
        /// </summary>
156
#if _PCL_
157
        public string ArchiveDirectoryPath { get; set; } = string.Empty;
158
#else
159
        public string ArchiveDirectoryPath { get; set; } = @"%LOCALAPPDATA%\{Processname}";
4✔
160
#endif
161

162
        /// <summary>
163
        ///     The extension of archived file, default this is ".log.gz"
164
        /// </summary>
165
        public string ArchiveExtension { get; set; } = ".log.gz";
4✔
166

167
        /// <summary>
168
        ///     Compress the archive
169
        /// </summary>
170
        public bool ArchiveCompress { get; set; } = true;
4✔
171

172
        /// <summary>
173
        ///     The amount of archived files which are allowed. The oldest is removed.
174
        /// </summary>
175
        public int ArchiveCount { get; set; } = 2;
4✔
176

177
        /// <summary>
178
        ///     The history of archived files, this could e.g. be stored in a configuration
179
        /// </summary>
180
        public IList<string> ArchiveHistory { get; set; } = new List<string>();
5✔
181

182
        /// <summary>
183
        ///     Enqueue the current information so it can be written to the file, formatting is done later.. (improves performance
184
        ///     for the UI)
185
        ///     Preferably do NOT pass huge objects which need to be garbage collected
186
        /// </summary>
187
        /// <param name="logInfo">LogInfo</param>
188
        /// <param name="messageTemplate">string</param>
189
        /// <param name="logParameters">params</param>
190
        public override void Write(LogInfo logInfo, string messageTemplate, params object[] logParameters)
191
        {
192
            if (_backgroundCancellationTokenSource.IsCancellationRequested)
48!
193
            {
194
                throw new OperationCanceledException("FileLogger has been disposed!", _backgroundCancellationTokenSource.Token);
×
195
            }
196
            if (PreFormat)
48!
197
            {
198
                _logItems.Enqueue(new Tuple<LogInfo, string, object[]>(logInfo, Format(logInfo, messageTemplate, logParameters), null));
×
199
            }
200
            else
201
            {
202
                _logItems.Enqueue(new Tuple<LogInfo, string, object[]>(logInfo, messageTemplate, logParameters));
48✔
203
            }
204
        }
48✔
205

206
        /// <summary>
207
        ///     A simple FormatWith
208
        /// </summary>
209
        /// <param name="source">string</param>
210
        /// <param name="variables">IDictionary</param>
211
        /// <returns>Formatted string</returns>
212
        private static string SimpleFormatWith(string source, IDictionary<string, object> variables)
213
        {
214
            var stringToFormat = source;
6✔
215
            var arguments = new List<object>();
6✔
216
            foreach (var key in variables.Keys)
48✔
217
            {
218
                var index = arguments.Count;
18✔
219
                if (!stringToFormat.Contains(key))
18✔
220
                {
221
                    continue;
222
                }
223

224
                // Replace the key with the index, so we can use normal formatting
225
                stringToFormat = stringToFormat.Replace(key, index.ToString());
12✔
226
                // Add the argument to the index, so the normal formatting can find this
227
                arguments.Add(variables[key]);
12✔
228
            }
229

230
            return string.Format(stringToFormat, arguments.ToArray());
6✔
231
        }
232

233
        /// <summary>
234
        ///     This is the implementation of the background task
235
        /// </summary>
236
        /// <returns>Task</returns>
237
        private async Task<bool> BackgroundAsync(CancellationToken cancellationToken = default)
238
        {
239
            while (!cancellationToken.IsCancellationRequested)
56,368,337!
240
            {
241
                // ReSharper disable once MethodSupportsCancellation
242
                await ProcessLinesAsync().ConfigureAwait(false);
56,368,337✔
243
                // Wait a while before we process the next items
244
                await Task.Delay(WriteInterval, cancellationToken).ConfigureAwait(false);
56,368,336✔
245
            }
246
            return true;
×
247
        }
×
248

249

250
        /// <summary>
251
        ///     Process the lines
252
        /// </summary>
253
        /// <param name="cancellationToken">CancellationToken</param>
254
        /// <returns>Task</returns>
255
        private async Task ProcessLinesAsync(CancellationToken cancellationToken = default)
256
        {
257
            if (_logItems.IsEmpty)
56,368,338✔
258
            {
259
                return;
56,368,335✔
260
            }
261
            var variables = new Dictionary<string, object>
2✔
262
            {
2✔
263
                {"Processname", Processname},
2✔
264
                {"Timestamp", DateTimeOffset.Now},
2✔
265
                {"Extension", Extension}
2✔
266
            };
2✔
267
            var expandedFilename = Environment.ExpandEnvironmentVariables(FilenamePattern);
2✔
268
            var directory = SimpleFormatWith(Environment.ExpandEnvironmentVariables(DirectoryPath), variables);
2✔
269

270
            // Filename of the file to write to.
271
            var filename = SimpleFormatWith(expandedFilename, variables);
2✔
272
            var filepath = Path.Combine(directory, filename);
2✔
273

274
            var isFilepathChange = !filepath.Equals(_previousFilePath);
2✔
275

276
            if (_previousFilePath != null && isFilepathChange)
2✔
277
            {
278
                // Archive!!
279
                try
280
                {
281
                    // Create the archive task
282
                    var archiveTask = ArchiveFileAsync(_previousFilePath, _previousVariables, cancellationToken);
1✔
283
                    // Add it to the list of current archive tasks
284
                    lock (_archiveTaskList)
1✔
285
                    {
286
                        _archiveTaskList.Add(archiveTask);
1✔
287
                    }
1✔
288
                    // Create a continue, so the task can be removed from the list, we do not use a CancellationToken or use the variable.
289
                    // ReSharper disable once MethodSupportsCancellation
290
                    // ReSharper disable once UnusedVariable
291
                    var ignoreThis = archiveTask.ContinueWith(async x =>
1✔
292
                    {
1✔
293
                        await x.ConfigureAwait(false);
1✔
294
                        lock (_archiveTaskList)
1✔
295
                        {
1✔
296
                            _archiveTaskList.Remove(x);
1✔
297
                        }
1✔
298
                    });
2✔
299
                }
1✔
300
                catch (Exception ex)
×
301
                {
302
                    Log.Error().WriteLine(ex, "Error archiving {0}", _previousFilePath);
×
303
                }
×
304
            }
305

306
            // check if the last file we wrote to is not the same.
307
            if (_previousFilePath is null || isFilepathChange)
2✔
308
            {
309
                // Store the current variables, so we can use them next time for the archiving
310
                _previousFilePath = filepath;
2✔
311
                _previousVariables = variables;
2✔
312
            }
313

314
            using var streamWriter = new StreamWriter(new MemoryStream(), _bomFreeUtf8Encoding)
2✔
315
            {
2✔
316
                AutoFlush = true
2✔
317
            };
2✔
318
            // Loop as long as there are items available
319
            while (_logItems.TryDequeue(out var logItem))
50✔
320
            {
321
                try
322
                {
323
                    var line = PreFormat ? logItem.Item2 : Format(logItem.Item1, logItem.Item2, logItem.Item3);
48!
324
                    await streamWriter.WriteAsync(line).ConfigureAwait(false);
48✔
325
                    // Check if we exceeded our buffer
326
                    if (streamWriter.BaseStream.Length > MaxBufferSize)
48!
327
                    {
328
                        break;
×
329
                    }
330
                }
48✔
331
                catch (Exception ex)
×
332
                {
333
                    Log.Error().WriteLine(ex, "Couldn't format passed log information, maybe this was owned by the UI?", null);
×
334
                    Log.Warn().WriteLine("LogInfo and messageTemplate for the problematic log information: {0} {1}", logItem.Item1, logItem.Item2);
×
335
                }
×
336
            }
337
            // Check if we wrote anything, if so store it to the file
338
            if (streamWriter.BaseStream.Length > 0)
2✔
339
            {
340
                try
341
                {
342
                    if (!Directory.Exists(directory))
2✔
343
                    {
344
                        Log.Info().WriteLine("Created directory {0}", directory);
1✔
345
                        Directory.CreateDirectory(directory);
1✔
346
                    }
347
                    streamWriter.BaseStream.Seek(0, SeekOrigin.Begin);
2✔
348
                    using var fileStream = new FileStream(filepath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
2✔
349
                    // Write UTF-8 BOM when it's a new file, this is detected by the length == 0
350
                    if (fileStream.Length == 0)
2✔
351
                    {
352
                        await fileStream.WriteAsync(Bom, 0, Bom.Length, cancellationToken);
2✔
353
                    }
354
                    await streamWriter.BaseStream.CopyToAsync(fileStream).ConfigureAwait(false);
2✔
355
                }
2✔
356
                catch (Exception ex)
×
357
                {
358
                    Log.Error().WriteLine(ex, "Error writing to logfile {0}", filepath);
×
359
                }
×
360
            }
361
        }
56,368,337✔
362

363

364
        /// <summary>
365
        ///     Archive the finished file
366
        /// </summary>
367
        /// <returns>Task to await for</returns>
368
        private async Task ArchiveFileAsync(string oldFile, IDictionary<string, object> oldVariables, CancellationToken cancellationToken = default)
369
        {
370
            var expandedArchiveFilename = Environment.ExpandEnvironmentVariables(ArchiveFilenamePattern);
1✔
371
            oldVariables["Extension"] = ArchiveExtension;
1✔
372
            var archiveDirectory = SimpleFormatWith(Environment.ExpandEnvironmentVariables(ArchiveDirectoryPath), oldVariables);
1✔
373

374
            // Filename of the file to write to.
375
            var archiveFilename = SimpleFormatWith(expandedArchiveFilename, oldVariables);
1✔
376
            var archiveFilepath = Path.Combine(archiveDirectory, archiveFilename);
1✔
377

378
            Log.Info().WriteLine("Archiving {0} to {1}", oldFile, archiveFilepath);
1✔
379

380
            if (!Directory.Exists(archiveDirectory))
1!
381
            {
382
                Directory.CreateDirectory(archiveDirectory);
×
383
            }
384
            ArchiveHistory.Add(archiveFilepath);
1✔
385
            if (!ArchiveCompress)
1!
386
            {
387
                await Task.Run(() => File.Move(oldFile, archiveFilepath), cancellationToken).ConfigureAwait(false);
×
388
            }
389
            else
390
            {
391
                using (var targetFileStream = new FileStream(archiveFilepath + ".tmp", FileMode.CreateNew, FileAccess.Write, FileShare.Read))
1✔
392
                using (var sourceFileStream = new FileStream(oldFile, FileMode.Open, FileAccess.Read, FileShare.Read))
1✔
393
                {
394
                    using var compressionStream = new GZipStream(targetFileStream, CompressionMode.Compress);
1✔
395
                    await sourceFileStream.CopyToAsync(compressionStream).ConfigureAwait(false);
1✔
396
                }
1✔
397
                // As the previous code didn't throw, we can now safely delete the old file
398
                File.Delete(oldFile);
1✔
399
                // And rename the .tmp file.
400
                File.Move(archiveFilepath + ".tmp", archiveFilepath);
1✔
401
            }
402

403
            while (ArchiveHistory.Count > ArchiveCount)
1!
404
            {
405
                var fileToRemove = ArchiveHistory[0];
×
406
                ArchiveHistory.RemoveAt(0);
×
407
                File.Delete(fileToRemove);
×
408
            }
409
        }
1✔
410

411
        private bool _disposedValue; // To detect redundant calls
412

413
        private void Dispose(bool disposing)
414
        {
415
            if (_disposedValue)
1!
416
            {
417
                return;
×
418
            }
419

420
            if (disposing)
1✔
421
            {
422
                _backgroundCancellationTokenSource.Cancel();
1✔
423
                try
424
                {
425
                    _backgroundTask.GetAwaiter().GetResult();
1✔
426
                }
×
427
                catch (TaskCanceledException)
1✔
428
                {
429
                    // Expected!
430
                    Log.Warn().WriteLine("Background task cancelled", null);
1✔
431
                }
1✔
432
                catch (Exception ex)
×
433
                {
434
                    Log.Error().WriteLine(ex, "Exception in background task.", null);
×
435
                }
×
436

437
                // Process leftovers
438
                try
439
                {
440
                    ProcessLinesAsync().Wait();
1✔
441
                }
1✔
442
                catch (Exception ex)
×
443
                {
444
                    Log.Error().WriteLine(ex, "Exception in cleanup.", null);
×
445
                }
×
446
                // Wait for archiving
447
                try
448
                {
449
                    List<Task> archiveTasksToWaitFor;
450
                    lock (_archiveTaskList)
1✔
451
                    {
452
                        archiveTasksToWaitFor = _archiveTaskList.ToList();
1✔
453
                    }
1✔
454
                    Task.WhenAll(archiveTasksToWaitFor).Wait();
1✔
455
                }
1✔
456
                catch (Exception ex)
×
457
                {
458
                    Log.Error().WriteLine(ex, "Exception in archiving.", null);
×
459
                }
×
460
            }
461

462
            _disposedValue = true;
1✔
463
        }
1✔
464

465
        // This code added to correctly implement the disposable pattern.
466
        void IDisposable.Dispose()
467
        {
468
            // Do not change this code. Put cleanup code in Dispose(bool disposing) above.
469
            Dispose(true);
1✔
470
        }
1✔
471
    }
472
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc