In this post I will go through the steps required to get an optimal install of JSNLog in ASP dotnet 6 projects using Serilog. JSNLog is a fantastic tool for logging uncaught javascript exceptions to the backend's logging system, and can be used for a variety of other things. This config includes batching of messages to reduce calls made to the server, as well as buffering, where helpful debugging logs are only sent if there's a fatal log that's also being sent, to make it easier to diagnose bugs without unnecessary diagnostic logs appearing at all times.
First you need to install JSNLog and Destructurama.JsonNet via NuGet. After that, make the following changes to these files.
_ViewImports.cshtml
Add @addTagHelper "*, jsnlog" to enable the tag helper.
usingDestructurama;usingJSNLog;...publicStartup(IConfigurationconfiguration,IWebHostEnvironmentenvironment){...Log.Logger=SerilogLogger.CreateSerilogLogger(serilogConfiguration).Destructure.JsonNetTypes().CreateLogger();...}...publicvoidConfigure(...,ILoggerFactoryloggerFactory){...loggerFactory.AddSerilog();JsnlogConfigurationjsnlogConfiguration=newJsnlogConfiguration{ajaxAppenders=newList<AjaxAppender>{newAjaxAppender{name="appender1",storeInBufferLevel="TRACE",// Log messages with severity smaller than TRACE are ignoredlevel="WARN",// Log messages with severity equal or greater than TRACE and lower than WARN are stored in the internal buffer, but not sent to the server// Log messages with severity equal or greater than WARN and lower than FATAL are sent to the server on their ownsendWithBufferLevel="FATAL",// Log messages with severity equal or greater than FATAL are sent to the server, along with all messages stored in the internal bufferbufferSize=20,// Stores the last up to 20 debug messages in browser memory,batchSize=20,batchTimeout=2000,// Logs are guaranteed to be sent within this period (in ms), even if the batch size has not been reached yetmaxBatchSize=50// When the server is unreachable and log messages are being stored until it is reachable again, this is the maximum number of messages that will be stored. Cannot be smaller than batchSize}},loggers=newList<Logger>{// Get the loggers to use the new appendernewLogger{appenders="appender1"}},insertJsnlogInHtmlResponses=false,// There's an outstanding bug setting this to true so the workaround is using the jl-javascript-logger-definitions tag helper in _Layout.cshtml, via the reference in _ViewImports.cshtmlproductionLibraryPath=null// We're using a fallback from the CDN with hashing, so do not use this};// Must be before UseStaticFiles and UseAuthorizationapp.UseJSNLog(newCustomLoggingAdapter(loggerFactory),jsnlogConfiguration);...}
_Layout.cshtml
This one has some interesting configuration.
Firstly, using local fallbacks if the CDN versions of the scripts are unavailable, or do not match their sha384 hashes (i.e. have been hacked or changed).
Secondly, it injects some additional information into the logs: client IP address, user ID (your model may differ from my example), and the user agent.
Thirdly, it sets up logging any uncaught JS exceptions, and any exceptions inside promises where no rejection method is provided.
Fourthly, using stacktrace.js it handles getting the relevant stacktrace using sourcemaps.
<scriptsrc="https://cdnjs.cloudflare.com/ajax/libs/jsnlog/2.30.0/jsnlog.min.js"asp-fallback-src="~/jsnlog.min.js"asp-fallback-test="window.JL"crossorigin="anonymous"integrity="sha384-ANmgu3V8Mc5/Usd/GeIS0xu0spgLKTIqkSMQAgVvV5C2SRp/rICkLLw5XG/u6BQ9"></script><scriptsrc="https://cdnjs.cloudflare.com/ajax/libs/stacktrace.js/2.0.2/stacktrace.min.js"asp-fallback-src="~/stacktrace.min.js"asp-fallback-test="window.StackTrace"crossorigin="anonymous"integrity="sha384-4PjQM+vlPbdcaPnFOyBIOfqz90Hvhp+QHb3rBMOy78OaxDHw9mnmzjUSNqJkn+W5"></script><jl-javascript-logger-definitions/><script>asyncfunctionfetchClientIp(){try{letresponse=awaitfetch('https://api.ipify.org?format=json');returnawaitresponse.json();}catch(error){JL().fatalException("Failed to get client IP for logging",error);}}fetchClientIp().then(data=>{constuserDetailsForLogging={'clientIp':data.ip,'userId':'@Model.UserId','userAgent':navigator.userAgent}@*LoguncaughtJavaScripterrorstotheserversidelog*@if(window){window.onerror=function(errorMsg,url,lineNumber,column,errorObj){varcallback=function(stackframes){varstringifiedStack=stackframes.map(function(sf){returnsf.toString();}).join('\n');constmsgObj={'msg':'Uncaught exception','sourceMapStack':stringifiedStack,'user':userDetailsForLogging};JL('serverLog').fatalException({msg:JSON.stringify(msgObj),errorMsg:errorMsg,url:url,lineNumber:lineNumber,column:column},errorObj);};varerrback=function(err){console.log(err.message);};StackTrace.fromError(errorObj).then(callback).catch(errback);returnfalse;};}@*LoguncaughtJavaScriptexceptionsinsidepromiseswherenorejectionmethodisprovided*@if(typeofwindow!=='undefined'){window.onunhandledrejection=function(event){varcallback=function(stackframes){varstringifiedStack=stackframes.map(function(sf){returnsf.toString();}).join('\n');constmsgObj={'msg':'Unhandled promise rejection','sourceMapStack':stringifiedStack,'user':userDetailsForLogging};JL("onerrorLogger").fatalException({msg:JSON.stringify(msgObj),errorMsg:event.reason?event.reason.message:null},event.reason);};varerrback=function(err){console.log(err.message);};StackTrace.fromError(errorObj).then(callback).catch(errback);returnfalse;};}});</script>
usingJSNLog;usingNewtonsoft.Json;usingSystem.Text;namespaceYourProjectHere{/// <summary>/// This adapter is required to get JavaScript objects logged by JSNLog to appear correctly in Serilog. Regretably it is required /// because of a defficiency in JSNLog that hasn't been fixed in at least 5 years at the time of writing, and depends on slightly /// customised versions of classes and methods taken from that library, included here in this one file for tidiness./// </summary>publicclassCustomLoggingAdapter:ILoggingAdapter{privatereadonlyILoggerFactory_loggerFactory;publicCustomLoggingAdapter(ILoggerFactoryloggerFactory){_loggerFactory=loggerFactory;}publicvoidLog(FinalLogDatafinalLogData){ILoggerlogger=_loggerFactory.CreateLogger(finalLogData.FinalLogger);Objectmessage=LogMessageHelpers.DeserializeIfPossible(finalLogData.FinalMessage);switch(finalLogData.FinalLevel){caseLevel.TRACE:logger.LogTrace("{@logMessage}",message);break;caseLevel.DEBUG:logger.LogDebug("{@logMessage}",message);break;caseLevel.INFO:logger.LogInformation("{@logMessage}",message);break;caseLevel.WARN:logger.LogWarning("{@logMessage}",message);break;caseLevel.ERROR:logger.LogError("{@logMessage}",message);break;caseLevel.FATAL:logger.LogCritical("{@logMessage}",message);break;default:break;}}}internalclassLogMessageHelpers{publicstaticTDeserializeJson<T>(stringjson){Tresult=JsonConvert.DeserializeObject<T>(json);returnresult;}publicstaticboolIsPotentialJson(stringmsg){stringtrimmedMsg=msg.Trim();return(trimmedMsg.StartsWith("{")&&trimmedMsg.EndsWith("}"));}/// <summary>/// Tries to deserialize msg./// If that works, returns the resulting object./// Otherwise returns msg itself (which is a string)./// </summary>/// <param name="msg"></param>/// <returns></returns>publicstaticObjectDeserializeIfPossible(stringmsg){try{if(IsPotentialJson(msg)){Objectresult=DeserializeJson<Object>(msg);returnresult;}}catch{}returnmsg;}/// <summary>/// Returns true if the msg contains a valid JSON string./// </summary>/// <param name="msg"></param>/// <returns></returns>publicstaticboolIsJsonString(stringmsg){try{if(IsPotentialJson(msg)){// Try to deserialise the msg. If that does not throw an exception,// decide that msg is a good JSON string.DeserializeJson<Dictionary<string,Object>>(msg);returntrue;}}catch{}returnfalse;}/// <summary>/// Takes a log message and finds out if it contains a valid JSON string./// If so, returns it unchanged./// /// Otherwise, surrounds the string with quotes (") and escapes the string for JavaScript./// </summary>/// <returns></returns>publicstaticstringEnsureValidJson(stringmsg){if(IsJsonString(msg)){returnmsg;}returnJavaScriptStringEncode(msg,true);}publicstaticstringJavaScriptStringEncode(stringvalue,booladdDoubleQuotes){#if NETFRAMEWORKreturnSystem.Web.HttpUtility.JavaScriptStringEncode(value,addDoubleQuotes);#else// copied from https://github.com/mono/mono/blob/master/mcs/class/System.Web/System.Web/HttpUtility.csif(String.IsNullOrEmpty(value))returnaddDoubleQuotes?"\"\"":String.Empty;intlen=value.Length;boolneedEncode=false;charc;for(inti=0;i<len;i++){c=value[i];if(c>=0&&c<=31||c==34||c==39||c==60||c==62||c==92){needEncode=true;break;}}if(!needEncode)returnaddDoubleQuotes?"\""+value+"\"":value;varsb=newStringBuilder();if(addDoubleQuotes)sb.Append('"');for(inti=0;i<len;i++){c=value[i];if(c>=0&&c<=7||c==11||c>=14&&c<=31||c==39||c==60||c==62)sb.AppendFormat("\\u{0:x4}",(int)c);elseswitch((int)c){case8:sb.Append("\\b");break;case9:sb.Append("\\t");break;case10:sb.Append("\\n");break;case12:sb.Append("\\f");break;case13:sb.Append("\\r");break;case34:sb.Append("\\\"");break;case92:sb.Append("\\\\");break;default:sb.Append(c);break;}}if(addDoubleQuotes)sb.Append('"');returnsb.ToString();#endif}}}