[How to] Log into a file instead of the Error Log

ludodepaz
Contributor

Logging with OneStream is great but when we all use the Error Log at the same time, things can get messy very quickly. Moreover, logging in a file instead of the Error Log can be very convenient when logging kickouts.
The initial setup can be a little involving but once you get a knack out of it, logging in a file is as easy as using the Error Log.
In this post, I will show you how to do the initial setup and then how to log into a file instead of the Error Log only when there is an exception or every time.
This rule and methodology is the result of the genius work of Matt Ha and I am very thankful he shared it with us.

Initial Setup

In order to log into a file, you need to import a Public Business Rule, it will be called by the logger and it is available in this post (GS_GlobalHelper.xml).
Side note, remember that in order to make a BR Public, you need to change the setting for ‘Contains Global Functions for Formulas’ to True.

ludodepaz_0-1698231778274.png

Setup of the BR to log into a file

In order to call a Public function, you need to Reference it in your Business Rule
ludodepaz_1-1698231951899.png
You also need to add it to your Imports:

Imports OneStream.BusinessRule.Finance.GS_GlobalHelper.MainClass

The Logger function needs 2 variables to be declared and set, I like to do it at the very top of my rule

#Region "Logging Variables"
Dim bVerboseLogging As String = True
Dim logger As New Text.StringBuilder
#End Region

Then, you need to add 2 Private Functions to your rule, this is where you set your naming convention (if you want to add the data and time or anything else) and the folder name where you want your files to be stored.

Private Sub StoreLoggerOnSession(ByVal si As SessionInfo, ByVal api As FinanceRulesApi, ByRef globals As BRGlobals)
	' Multithread-safe way to store logger to session
	Dim globalLogger As Text.StringBuilder = globals.GetObject("globalLogger")

	If globalLogger Is Nothing Then
		globals.SetObject("globalLogger", logger)
	Else
	    globalLogger.AppendLine(logger.ToString)
	End If
End Sub
                       
Private Sub WriteSessionLogToFileShare(ByVal si As SessionInfo, ByVal api As FinanceRulesApi, ByRef globals As BRGlobals, ByVal logName As String)
	' Write logger stored on session to application database
    Dim globalLogger As Text.StringBuilder = globals.GetObject("globalLogger")
                                   
    If globalLogger IsNot Nothing Then
	    WriteLogger(si, api, globalLogger, logName, "TestLogs", DateTime.Now.ToString("yyyy-MM-dd-hh-mm"))
    End If
End Sub		

In the end, your rule should look like this:

ludodepaz_2-1698232197406.png

Logging into a file when there is an Exception

You can write to a file instead of the Error log when an Exception happens, in this case, update the Exception Catcher at the end of your Main Function.

Catch ex As Exception
	logger.AppendLine(ex.Message)
	If bVerboseLogging Then StoreLoggerOnSession(si, api, globals)
	WriteSessionLogToFileShare(si, api, globals, "MyRule")
	Throw ErrorHandler.LogWrite(si, New XFException(si, ex))
End Try

Logging anything into a file

Of course, you can also log anything you would usually log to the Error Log in a file instead. Use the “logger” instead of the Error Log.

'Log to the Error Log
api.LogMessage("Logging to the Error Log with the api")
brapi.ErrorLog.LogMessage(si, "Logging to the Error Log with the brapi")

'Log to a file
logger.AppendLine("Logging to a file").AppendLine

#Region "Push logs to the File Explorer"
If bVerboseLogging Then StoreLoggerOnSession(si, api, globals)
WriteSessionLogToFileShare(si, api, globals, "MyRule")
#End Region

Results

This is what you get in the Error Log

ludodepaz_3-1698232458413.png
And this is what you get in the File Explorer:
ludodepaz_4-1698232458421.png
5 REPLIES 5

Sergey
Contributor III

This is neat (also a little bit more script than a regular brapi.errorlog.logmessage 😁 ).

When using it, did you see any differences in the time to log informations, using this method vs. logmessage ?

Regards,

Hi Sergey,

when we think about it, every time you hit the Error Log, you write a row in a table, this is very costly. With this solution, you put all logs in memory and then write a file so in the end, it's actually faster, especially if you write kick outs and have a lot of them.

JackLacava
Honored Contributor

Thanks Ludo, this is very appreciated.

The issue of advanced logging came up a few times at Splash EMEA, it's a common issue for complex applications. Your solution is cool but makes some debatable choices (logs in a public folder, no protection against mistakenly keeping it on in Production, no on-off debug switch...); my feeling is that this sort of thing should be baked into the product, with better defaults that we have now. At the moment, every developer has to reinvent the wheel, with various degrees of success.

If you feel like, you could post a request on IdeaStream for more fine-grained logging, I'm sure it would gain support. You could also consider publishing this on OpenPlace.

Hi Jack,

that's an excellent idea, I will do that. I know some partners in the US do it on every project.

MarkusWipp
Contributor

Hi all, 

I share my solution to it (this still logs to the standard OS logs):

You can put it into an extender business rule as a separate namespace in this case.

 

 

 

Namespace Common.Logging
    Public Interface ILogging
        Sub LogTrace(message As String)
        Sub LogDebug(message As String)
        Sub LogError(message As String)
        Sub LogMessage(message As String)
    End Interface

    Public Enum LogLevel
        LogTrace = 4
        LogDebug = 3
        LogMessage = 2
        LogError = 1
    End Enum

    Public Class OneStreamLogging
        Inherits DefaultLogging

		Public Const defaultMessageHeading = "OneStreamLogging Messages"
        Private si As SessionInfo
        Private userInLoggingGroup As Boolean
        Private collectLogs As Boolean
        Private collectedLogs As New Text.StringBuilder()
		Private messageHeading As String

        Public Sub New(ByVal si As SessionInfo, Optional logLevel As LogLevel = LogLevel.LogError, Optional withTiming As Boolean = False, Optional collectLogs As Boolean = False, Optional messageHeading As String = defaultMessageHeading)
            MyBase.New(logLevel, withTiming)
            Me.si = si
            Me.collectLogs = collectLogs
            Me.userInLoggingGroup = BRApi.Security.Authorization.IsUserInGroup(si, si.UserName, "ALLC_LoggingEnabledUsers", False)
			Me.messageHeading = messageHeading
        End Sub

        Protected Overrides Sub WriteMessage(loglevel As LogLevel, message As String)
            If loglevel.Equals(LogLevel.LogError) Then
                If collectLogs Then
                    collectedLogs.AppendLine($"{loglevel}: {message}")
                Else
                    BRApi.ErrorLog.LogError(si, New XFException(message))
                End If
            Else
                If userInLoggingGroup Then
                    If collectLogs Then
                        collectedLogs.AppendLine($"{loglevel}: {message}")
                    Else
						If messageHeading.Equals(defaultMessageHeading) Then
							BRApi.ErrorLog.LogMessage(si, message)
						Else
                        	BRApi.ErrorLog.LogMessage(si, Me.messageHeading, message)
						End If
                    End If
                End If
            End If
        End Sub

        Public Sub LogCollectedLogs()
			If collectedLogs.Length > 0 Then
				If messageHeading.Equals(defaultMessageHeading) Then
					BRApi.ErrorLog.LogMessage(si, collectedLogs.ToString())
				Else
            		BRApi.ErrorLog.LogMessage(si, Me.messageHeading, collectedLogs.ToString())
				End If
			End If
        End Sub

    End Class

    Public Class DefaultLogging
        Implements ILogging

        Private desiredLogLevel As LogLevel
        Private withTiming As Boolean
        Private elapsedTime As New Stopwatch()
        Public Sub New(Optional logLevel As LogLevel = LogLevel.LogError, Optional withTiming As Boolean = False)
            Me.desiredLogLevel = logLevel
            Me.withTiming = withTiming
            If Me.withTiming Then
                elapsedTime.Start()
            End If
            Me.LogTrace("Initialized Logger")
        End Sub
        Public Sub LogTrace(message As String) Implements ILogging.LogTrace
            Log(LogLevel.LogTrace, "TRACE", message)
        End Sub

        Public Sub LogDebug(message As String) Implements ILogging.LogDebug
            Log(LogLevel.LogDebug, "DEBUG", message)
        End Sub

        Public Sub LogError(message As String) Implements ILogging.LogError
            Log(LogLevel.LogError, "ERROR", message)
        End Sub

        Public Sub LogMessage(message As String) Implements ILogging.LogMessage
            Log(LogLevel.LogMessage, "MESSAGE", message)
        End Sub

        Private Sub Log(loglevel As LogLevel, levelname As String, message As String)
            If (loglevel <= Me.desiredLogLevel) Then
                Dim stackframe As New Diagnostics.StackFrame(2)

                Dim baseFormat = $"{levelname}: {stackframe.GetMethod().DeclaringType.Namespace}:{stackframe.GetMethod().DeclaringType.Name}:{stackframe.GetMethod().Name}: "
                If withTiming Then
                    Dim ts As TimeSpan = elapsedTime.Elapsed
                    Dim formattedElapsedTime = String.Format("{0:00}:{1:00}:{2:00}.{3:00}", ts.Hours, ts.Minutes, ts.Seconds, ts.Milliseconds / 10)
                    WriteMessage(loglevel, $"{baseFormat}: {formattedElapsedTime}: {message}")
                Else
                    WriteMessage(loglevel, $"{baseFormat}: {message}")
                End If
            End If
        End Sub

        Protected Overridable Sub WriteMessage(loglevel As LogLevel, message As String)
            Console.WriteLine(message)
        End Sub

    End Class
End Namespace

 

 

and then use e.g. like this from other BRs (of course you have to reference the above Business Rule

 

 

 

Imports Common.Logging

Namespace OneStream.BusinessRule.Extender.Test
	Public Class MainClass
		Public Function Main(ByVal si As SessionInfo, ByVal globals As BRGlobals, ByVal api As Object, ByVal args As DashboardStringFunctionArgs) As Object
			Dim logger As OneStreamLogging = New OneStreamLogging(si, LogLevel.LogDebug, True, True, $"Test Logging")
			Try
				logger.LogMessage("This is a message")
				logger.LogDebug("This is a debug message")
				logger.LogTrace("This is a trace massage")
				logger.LogError("This is an error message")
			Catch ex As Exception
				Throw ErrorHandler.LogWrite(si, New XFException(si, ex))
			Finally
				logger.LogCollectedLogs()
			End Try
		End Function
	End Class
End Namespace

 

 

 

This allows you the follwoing:

  • can be used outside onestream and logs to the console
  • only log for users in security group ALLC_LoggingEnabledUsers
  • only log messages up to e certain log-level
  • collect logs during an execution and only log everything at once on the end
  • logs additional useful information / timing / stack trace /etc.

Hope that is useful to some of you

Markus