Coding Horror

programming and human factors

Logging TraceListener

I'm working on a console app that needs to provide integrated logging of its own output. Sure, you could do a standard console output redirect, but I wanted the app to be responsible for logging its own output. I decided to write my own TraceListener that automatically creates IIS-style cyclic logfiles using the Trace method, like so:

  
Sub Main()  
AddListeners(False)  
Trace.WriteLine("Hello World!")  
Trace.WriteLine("Hello World!", "category1")  
Dim h As New Hashtable  
Trace.WriteLine(h)  
Trace.WriteLine(h, "category2")  
For i As Integer = 0 To 99  
Trace.WriteLine("Line " & i)  
Next  
End Sub  
Private Sub AddListeners(ByVal DoLog As Boolean)  
'-- this causes Trace.Write to
'-- mimic Console.Write
Dim t As New TextWriterTraceListener(System.Console.Out)  
Trace.Listeners.Add(t)  
'-- this enables IIS-style logging
If DoLog Then  
Dim ct As New CyclicLogTraceListener  
ct.FolderName = "."  
ct.FileCountThreshold = 3  
ct.FileSizeThreshold = 3500  
ct.FileSizeUnit = CyclicLogTraceListener.SizeUnit.Bytes  
ct.FileNameTemplate = "{0:0000}.log"  
ct.TimeStampFormat = "yyyy-dd-MM hh:mm:ss"  
ct.AddMethod = True  
ct.AddPidTid = True  
ct.FieldSeparator = ", "  
Trace.Listeners.Add(ct)  
End If  
End Sub  

You can either add the listener in code, as above, or more dynamically via the System.Diagnostics section of the .config file:

  
<system.diagnostics>
<trace autoflush="true" indentsize="4">
<listeners>
<add name="CyclicLog" type="ConsoleApp.CyclicLogTraceListener,ConsoleApp"
initializeData="fileSizeThreshold=5000, fileCountThreshold=3, addPidTid=True" />  
</listeners>
</trace>
</system.diagnostics>

This results in a log file named 0000.log in the application folder that looks like so:

2005-07-03 12:25:43, 1392/1476, ConsoleApp.Module1.Main, Hello World!
2005-07-03 12:25:43, 1392/1476, ConsoleApp.Module1.Main, category1, Hello World!
2005-07-03 12:25:43, 1392/1476, ConsoleApp.Module1.Main, System.Collections.Hashtable
2005-07-03 12:25:43, 1392/1476, ConsoleApp.Module1.Main, category2, System.Collections.Hashtable
2005-07-03 12:25:43, 1392/1476, ConsoleApp.Module1.Main, Line 0
2005-07-03 12:25:43, 1392/1476, ConsoleApp.Module1.Main, Line 1
2005-07-03 12:25:43, 1392/1476, ConsoleApp.Module1.Main, Line 2
2005-07-03 12:25:43, 1392/1476, ConsoleApp.Module1.Main, Line 3
2005-07-03 12:25:43, 1392/1476, ConsoleApp.Module1.Main, Line 4

The neat thing is that we get this behavior for free-- as long as I use Trace.WriteLine instead of Console.WriteLine, my console app logs its own output, and I can easily modify the logging behavior post-deployment by editing the .config file.

Code follows...

Here's the complete CyclicLogTraceListener class:

Imports System
Imports System.Diagnostics
Imports System.IO
Imports System.Reflection
Imports System.Text
Imports System.Text.RegularExpressions
Public Class CyclicLogTraceListener
Inherits TraceListener
Private Const _StackFrameSkipCount As Integer = 5
Private Const _IndentCharacter As Char = " "c
Private _FileIndex As Long = 0
Private _FirstLogFound As Boolean = False
Private _FileNameTemplateHasFormatting As Boolean = False
Private _FileLength As Long = 0
Private _FileCreationDate As DateTime = DateTime.MinValue
Private _sw As StreamWriter
#Region "  Properties"
Private _FolderName As String
Private _FieldSeparator As String
Private _FileSizeThreshold As Long
Private _FileSizeUnit As SizeUnit
Private _FileCountThreshold As Long
Private _FileName As String
Private _FileNameTemplate As String
Private _TimeStampFormat As String
Private _AddMethod As Boolean
Private _AddPidTid As Boolean
Private _AutoFlush As Boolean
Private _FileAgeThreshold As Long
Private _FileAgeUnit As AgeUnit
''' <summary>
''' Indicates what unit of time FileAgeThreshold represents
''' </summary>
Public Enum AgeUnit
Minutes
Hours
Days
Weeks
Months
End Enum
''' <summary>
''' Indicates what unit of size FileBytesThreshold represents
''' </summary>
Public Enum SizeUnit
Gigabytes
Megabytes
Kilobytes
Bytes
End Enum
''' <summary>
''' If true, log file is flushed after every write.
''' Can also be set via trace="autoflush" in the
''' system.diagnostics .config file section
''' </summary>
Public Property AutoFlush() As Boolean
Get
Return _AutoFlush
End Get
Set(ByVal Value As Boolean)
_AutoFlush = Value
End Set
End Property
''' <summary>
''' Folder that log files will be written to.
''' Defaults to current folder.
''' </summary>
Public Property FolderName() As String
Set(ByVal Value As String)
_FolderName = Value
If Not _FolderName.EndsWith(Path.DirectorySeparatorChar) Then
_FolderName = _FolderName & Path.DirectorySeparatorChar
End If
If Not Directory.Exists(_FolderName) Then
Throw New DirectoryNotFoundException("Requested trace logging directory '" & _FolderName & "' does not exist")
End If
End Set
Get
Return _FolderName
End Get
End Property
''' <summary>
''' Seperator used between log field entries.
''' Defaults to comma.
''' </summary>
Public Property FieldSeparator() As String
Set(ByVal Value As String)
_FieldSeparator = Value
End Set
Get
Return _FieldSeparator
End Get
End Property
''' <summary>
''' Template used to generate log filenames
''' supports standard String.Format for two values: file index {0:} and current date {1:}
''' using the standard String.Format conventions
''' Defaults to "{0:0000}.log"
''' </summary>
Public Property FileNameTemplate() As String
Set(ByVal Value As String)
_FileNameTemplate = Value
_FileNameTemplateHasFormatting = Regex.IsMatch(_FileNameTemplate, "{(0|1):.*}")
End Set
Get
Return _FileNameTemplate
End Get
End Property
''' <summary>
''' Add the method name of the calling function to the log.
''' Defaults to True.
''' </summary>
Public Property AddMethod() As Boolean
Set(ByVal Value As Boolean)
_AddMethod = Value
End Set
Get
Return _AddMethod
End Get
End Property
''' <summary>
''' Add the process and thread ID to the log.
''' Defaults to False.
''' </summary>
Public Property AddPidTid() As Boolean
Set(ByVal Value As Boolean)
_AddPidTid = Value
End Set
Get
Return _AddPidTid
End Get
End Property
''' <summary>
''' If a format string is provided, the time will be added to each log entry.
''' Defaults to "yyyy-MM-dd hh:mm:ss". Set to empty string to disable.
''' </summary>
Public Property TimeStampFormat() As String
Set(ByVal Value As String)
_TimeStampFormat = Value
End Set
Get
Return _TimeStampFormat
End Get
End Property
''' <summary>
''' Maximum number of log files to create.
''' Defaults to 10000.
''' </summary>
Public Property FileCountThreshold() As Long
Set(ByVal Value As Long)
_FileCountThreshold = Value
End Set
Get
Return _FileCountThreshold
End Get
End Property
''' <summary>
''' Maximum age, in FileAgeUnits, of log files before a new log file will be created
''' Defaults to 0, infinite
''' </summary>
Public Property FileAgeThreshold() As Long
Get
Return _FileAgeThreshold
End Get
Set(ByVal Value As Long)
_FileAgeThreshold = Value
End Set
End Property
''' <summary>
''' Determines what time unit is represented in FileAgeThreshold.
''' Defaults to AgeUnit.Days
''' </summary>
Public Property FileAgeUnit() As AgeUnit
Get
Return _FileAgeUnit
End Get
Set(ByVal Value As AgeUnit)
_FileAgeUnit = Value
End Set
End Property
''' <summary>
''' Maximum file size each log is allowed to grow to before a new log is created.
''' Defaults to 512kb.
''' </summary>
Public Property FileSizeThreshold() As Long
Set(ByVal Value As Long)
_FileSizeThreshold = Value
End Set
Get
Return (_FileSizeThreshold)
End Get
End Property
''' <summary>
''' Determines what size unit is represented in FileSizeThreshold.
''' Defaults to Bytes.
''' </summary>
Public Property FileSizeUnit() As SizeUnit
Get
Return _FileSizeUnit
End Get
Set(ByVal Value As SizeUnit)
_FileSizeUnit = Value
End Set
End Property
#End Region
#Region "  Public Methods"
''' <summary>
''' constructor contains defaults if values aren't specified
''' </summary>
Public Sub New()
Me.FileNameTemplate = "{0:0000}.log"
_FolderName = "."
_FileSizeThreshold = 1
_FileSizeUnit = SizeUnit.Megabytes
_FileCountThreshold = 10000
_TimeStampFormat = "yyyy-dd-MM hh:mm:ss"
_AddMethod = False
_AddPidTid = False
_FieldSeparator = ", "
_FileAgeUnit = AgeUnit.Days
_FileAgeThreshold = 0
_AutoFlush = True
End Sub
''' <summary>
''' this method is used when trace configured via the system.diagnostics section of the .config file
''' all the parameters are set via a single initializeData string in this format:
'''   "booleanValue=true, stringValue='string', longValue=567"
''' </summary>
Public Sub New(ByVal initializeData As String)
Me.New()
FolderName = ParseString(initializeData, "folderName", _FolderName)
_FileSizeThreshold = ParseLong(initializeData, "fileSizeThreshold", _FileSizeThreshold)
_FileSizeUnit = DirectCast(ParseEnum(initializeData, "fileSizeUnit", _FileSizeUnit, GetType(SizeUnit)), SizeUnit)
_FileCountThreshold = ParseLong(initializeData, "fileCountThreshold", _FileCountThreshold)
_FileAgeThreshold = ParseLong(initializeData, "fileAgeThreshold", _FileAgeThreshold)
_FileAgeUnit = DirectCast(ParseEnum(initializeData, "fileAgeUnit", _FileAgeUnit, GetType(AgeUnit)), AgeUnit)
_FileNameTemplate = ParseString(initializeData, "fileNameTemplate", _FileNameTemplate)
_TimeStampFormat = ParseString(initializeData, "timeStampFormat", _TimeStampFormat)
_AddPidTid = ParseBoolean(initializeData, "addPidTid", _AddPidTid)
_AddMethod = ParseBoolean(initializeData, "addMethod", _AddMethod)
_FieldSeparator = ParseString(initializeData, "fieldSeparator", _FieldSeparator)
End Sub
#Region "  Initialization Parsing"
Private Function ParseEnum(ByVal initializeData As String, ByVal name As String, _
ByVal defaultValue As Object, ByVal t As Type) As Object
Dim s As String = ParseString(initializeData, name, defaultValue.ToString)
If s = "" Then
Return defaultValue
End If
Dim o As Object
Try
o = System.Enum.Parse(t, s, True)
Catch ex As System.ArgumentException
'-- if the string representation provided doesn't match
'-- any known enum (case, we'll get this exception
End Try
If o Is Nothing Then
Return defaultValue
Else
Return o
End If
End Function
''' <summary>
''' parses values of the form
''' name=true, name=false
''' </summary>
Private Function ParseBoolean(ByVal initializeData As String, ByVal name As String, ByVal defaultValue As Boolean) As Boolean
Dim m As Match = Regex.Match(initializeData, "(?<=" & name & "=)false|true", RegexOptions.IgnoreCase)
If m.Success Then
Return Boolean.Parse(m.Value)
Else
Return defaultValue
End If
End Function
''' <summary>
''' parses values of the form
''' name=3, name=28932
''' </summary>
Private Function ParseLong(ByVal initializeData As String, ByVal name As String, ByVal defaultValue As Long) As Long
Dim m As Match = Regex.Match(initializeData, "(?<=" & name & "=)d+", RegexOptions.IgnoreCase)
If m.Success Then
Return Long.Parse(m.Value)
Else
Return defaultValue
End If
End Function
''' <summary>
''' parses values of the form
''' name='data', name="data", name=data
''' </summary>
Private Function ParseString(ByVal initializeData As String, ByVal name As String, ByVal defaultValue As String) As String
Dim m As Match = Regex.Match(initializeData, "(?<=" & name & "=('|"")*)[^'"",]+", RegexOptions.IgnoreCase)
If m.Success Then
Return m.Value
Else
'-- check for the ='' ="" =, case (empty string)
If Regex.IsMatch(initializeData, name & "=['"",]['""]*", RegexOptions.IgnoreCase) Then
Return ""
Else
Return defaultValue
End If
End If
End Function
#End Region
Public Overloads Overrides Sub Write(ByVal o As Object)
WriteMessage(FormatMessage(o.ToString, "", False))
End Sub
Public Overloads Overrides Sub Write(ByVal message As String)
WriteMessage(FormatMessage(message, "", False))
End Sub
Public Overloads Overrides Sub Write(ByVal message As String, ByVal category As String)
WriteMessage(FormatMessage(message, category, False))
End Sub
Public Overloads Overrides Sub Write(ByVal o As Object, ByVal category As String)
WriteMessage(FormatMessage(o.ToString, category, False))
End Sub
Public Overloads Overrides Sub WriteLine(ByVal o As Object)
WriteMessage(FormatMessage(o.ToString, "", True))
End Sub
Public Overloads Overrides Sub WriteLine(ByVal message As String)
WriteMessage(FormatMessage(message, "", True))
End Sub
Public Overloads Overrides Sub WriteLine(ByVal message As String, ByVal category As String)
WriteMessage(FormatMessage(message, category, True))
End Sub
Public Overloads Overrides Sub WriteLine(ByVal o As Object, ByVal category As String)
WriteMessage(FormatMessage(o.ToString, category, True))
End Sub
Public Overrides Sub Close()
SyncLock Me
CloseLogFile()
End SyncLock
End Sub
Public Overrides Sub Flush()
SyncLock Me
If Not _sw Is Nothing Then
_sw.Flush()
End If
End SyncLock
End Sub
#End Region
#Region "  Private Methods"
Private Function FormatMessage(ByVal message As String, ByVal category As String, ByVal includeNewLine As Boolean) As String()
Return New String() {GetIndent(), GetTimeStamp(), GetPidTid(), GetMethodName(), GetCategory(category), message, GetNewLine(includeNewLine)}
End Function
''' <summary>
''' creates a new log filename in this format
'''   "Directory  FileNameTemplate"
''' </summary>
Private Function CreateLogFileName(ByVal fileIndex As Long) As String
Dim sb As New StringBuilder
sb.Append(_FolderName)
sb.Append(String.Format(_FileNameTemplate, fileIndex, DateTime.Now))
Return sb.ToString
End Function
''' <summary>
''' Check that no more than (n) log files will exist at any given time;
''' if more than (n) do exist, the oldest one is deleted
''' </summary>
Private Sub EnforceFileThreshold()
If _FileCountThreshold = 0 Then Return
'-- get all the files in the current folder..
Dim FileNames() As String
If Path.GetExtension(_FileNameTemplate) = "" Then
FileNames = Directory.GetFiles(_FolderName)
Else
'-- ..that end with whatever log extension was specified
FileNames = Directory.GetFiles(_FolderName, "*" & Path.GetExtension(_FileNameTemplate))
End If
If FileNames.Length = 0 Then Return
Dim FilesMatched As Integer = 0
Dim OldestFileDate As DateTime = DateTime.MinValue
Dim OldestFileName As String = ""
'-- find all the files that match our specific log pattern
'-- (extension isn't specific enough
Dim FilePattern As String = Regex.Replace(_FileNameTemplate, "{[^}]+?}", ".*?") & "$"
Dim r As New Regex(FilePattern)
Dim fi As FileInfo
For Each FileName As String In FileNames
If r.IsMatch(FileName) Then
FilesMatched += 1
fi = New FileInfo(FileName)
If fi.CreationTimeUtc > OldestFileDate Then
OldestFileDate = fi.CreationTimeUtc
OldestFileName = FileName
End If
End If
Next
If FilesMatched > _FileCountThreshold Then
File.Delete(OldestFileName)
End If
End Sub
''' <summary>
''' Opens the "current" log file; this can be either an
''' existing incomplete log file or a brand new log file
''' </summary>
Private Sub OpenLogFile(ByVal messageLength As Long)
'-- close any currently open log file, if any
CloseLogFile()
Dim FileName As String
Dim LoopCount As Integer = 0
Do While True
LoopCount += 1
'-- generate next log name in sequence (by date, index, etc)
If _FileCountThreshold = 0 Then
_FileIndex = 1
Else
_FileIndex += 1
If _FileIndex > _FileCountThreshold Then
_FileIndex = 1
End If
End If
FileName = CreateLogFileName(_FileIndex - 1)
'-- see if next log file already exists
If Not File.Exists(FileName) Then
'-- this will be a new log file
_FileLength = 0
_FileCreationDate = DateTime.MinValue
'-- if creating a new file, we need to make ABSOLUTELY
'-- sure we haven't exceeded total allowed file count
EnforceFileThreshold()
Exit Do
Else
'-- existing log file; retrieve length and creation time
Dim fi As New FileInfo(FileName)
_FileLength = fi.Length
_FileCreationDate = fi.CreationTimeUtc
'-- has this log file exceeded valid length or age?
If LogFileSizeMaxReached(messageLength) Or LogFileAgeMaxReached() Then
If _FirstLogFound Or (LoopCount > _FileCountThreshold) Then
File.Delete(FileName)
_FileLength = 0
Exit Do
End If
Else
Exit Do
End If
End If
Loop
'-- this is an optimization for subsequent passes through the loop
_FirstLogFound = True
'-- at this point we're either..
'-- A) opening a brand new logfile
'-- B) appending to an existing logfile
_sw = File.AppendText(FileName)
_sw.AutoFlush = _AutoFlush
End Sub
Private Sub CloseLogFile()
SyncLock Me
If (Not _sw Is Nothing) Then
_sw.Close()
_sw = Nothing
End If
End SyncLock
End Sub
Private Function StringArrayLength(ByVal message As String()) As Long
Dim ml As Long = 0
For i As Integer = 0 To message.Length - 1
ml += message(i).Length
Next
Return ml
End Function
Private Sub WriteMessage(ByVal message As String())
Dim ml As Long = StringArrayLength(message)
SyncLock Me
If _sw Is Nothing Then
OpenLogFile(ml)
Else
If LogFileSizeMaxReached(ml) Or LogFileAgeMaxReached() Then
OpenLogFile(ml)
End If
End If
For i As Integer = 0 To message.Length - 1
_sw.Write(message(i))
Next
_FileLength += ml
End SyncLock
End Sub
Private Function GetMethodName() As String
If _AddMethod Then
Dim sf As New StackFrame(_StackFrameSkipCount)
Dim mb As MethodBase = sf.GetMethod
Dim sb As New StringBuilder
sb.Append(mb.ReflectedType.FullName)
sb.Append(".")
sb.Append(mb.Name)
sb.Append(_FieldSeparator)
Return sb.ToString
Else
Return ""
End If
End Function
Private Function GetIndent() As String
Return New String(_IndentCharacter, (Me.IndentLevel * Me.IndentSize))
End Function
Private Function GetCategory(ByVal category As String) As String
If category = "" Then
Return ""
Else
Dim sb As New StringBuilder
sb.Append(category)
sb.Append(_FieldSeparator)
Return sb.ToString
End If
End Function
Private Function GetNewLine(ByVal includeNewLine As Boolean) As String
If includeNewLine Then
Return Environment.NewLine
Else
Return ""
End If
End Function
Private Function GetPidTid() As String
If _AddPidTid Then
Dim sb As New StringBuilder
sb.Append(Process.GetCurrentProcess.Id)
sb.Append("/")
sb.Append(AppDomain.GetCurrentThreadId)
sb.Append(_FieldSeparator)
Return sb.ToString
Else
Return ""
End If
End Function
Private Function GetTimeStamp() As String
If _TimeStampFormat = "" Then
Return ""
Else
Dim sb As New StringBuilder
sb.Append(DateTime.Now.ToString(_TimeStampFormat))
sb.Append(_FieldSeparator)
Return sb.ToString
End If
End Function
Private Function LogFileAgeMaxReached() As Boolean
If _FileAgeThreshold = 0 Then
Return False
Else
If _FileCreationDate = DateTime.MinValue Then
Return False
End If
Select Case _FileAgeUnit
Case AgeUnit.Hours
Return _FileCreationDate < DateTime.UtcNow.AddHours(-_FileAgeThreshold)
Case AgeUnit.Minutes
Return _FileCreationDate < DateTime.UtcNow.AddMinutes(-_FileAgeThreshold)
Case AgeUnit.Months
Return _FileCreationDate < DateTime.UtcNow.AddMonths(-Convert.ToInt32(_FileAgeThreshold))
Case AgeUnit.Weeks
Return _FileCreationDate < DateTime.UtcNow.AddDays(-(_FileAgeThreshold * 7))
Case Else
'-- default to days
Return _FileCreationDate < DateTime.UtcNow.AddDays(-_FileAgeThreshold)
End Select
End If
End Function
Private Function LogFileSizeMaxReached(ByVal messageLength As Long) As Boolean
If _FileSizeThreshold = 0 Then
Return False
Else
Dim l As Long = messageLength + _FileLength
Select Case _FileSizeUnit
Case SizeUnit.Kilobytes
Return l > (_FileSizeThreshold * 1024)
Case SizeUnit.Megabytes
Return l > (_FileSizeThreshold * 1048576)
Case SizeUnit.Gigabytes
Return l > (_FileSizeThreshold * 1073741824)
Case Else
'-- default to bytes
Return l >= _FileSizeThreshold
End Select
End If
End Function
#End Region
Protected Overrides Sub Finalize()
Me.Close()
MyBase.Finalize()
End Sub
End Class

Written by Jeff Atwood

Indoor enthusiast. Co-founder of Stack Exchange and Discourse. Disclaimer: I have no idea what I'm talking about. Find me here: http://twitter.com/codinghorror