10-25-2023 07:22 AM
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.
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.
In order to call a Public function, you need to Reference it in your Business Rule
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:
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
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
This is what you get in the Error Log
10-26-2023 02:26 AM
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,
10-26-2023 05:57 AM
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.
10-26-2023 05:53 AM - edited 10-26-2023 05:55 AM
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.
10-26-2023 05:59 AM
Hi Jack,
that's an excellent idea, I will do that. I know some partners in the US do it on every project.
10-26-2023 10:35 AM - edited 10-26-2023 10:36 AM
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:
Hope that is useful to some of you
Markus