From 8e85cbc31210e09ba630460cb028c903905ce2ec Mon Sep 17 00:00:00 2001 From: Rolf Kristensen Date: Sat, 14 Mar 2026 13:18:41 +0100 Subject: [PATCH] Replaced Newtonsoft.Json with NLog JsonLayout and support NLog v6 --- .gitignore | 407 +++++++++++++++++- BetterStack.Logs.NLog.csproj | 17 +- .../BetterStackJsonLayout.cs | 150 +++++++ .../BetterStackLogsTarget.cs | 136 +++--- BetterStack.Logs.NLog/ColorValueFormatter.cs | 4 +- BetterStack.Logs/Client.cs | 135 +++--- BetterStack.Logs/Drain.cs | 77 ++-- BetterStack.Logs/Log.cs | 22 - example-project/nlog.config | 1 + 9 files changed, 747 insertions(+), 202 deletions(-) create mode 100644 BetterStack.Logs.NLog/BetterStackJsonLayout.cs delete mode 100644 BetterStack.Logs/Log.cs diff --git a/.gitignore b/.gitignore index 13da714..898563d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,403 @@ -bin/ -obj/ -global.json +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore -example-project/bin/ -example-project/obj/ +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates +*.env + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Build results on 'Bin' directories +**/[Bb]in/* +# Uncomment if you have tasks that rely on *.refresh files to move binaries +# (https://github.com/github/gitignore/pull/3736) +#!**/[Bb]in/*.refresh + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +*.trx + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Approval Tests result files +*.received.* + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.idb +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +**/.paket/paket.exe +paket-files/ + +# FAKE - F# Make +**/.fake/ + +# CodeRush personal settings +**/.cr/personal + +# Python Tools for Visual Studio (PTVS) +**/__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +#tools/** +#!tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog +MSBuild_Logs/ + +# AWS SAM Build and Temporary Artifacts folder +.aws-sam + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +**/.mfractor/ + +# Local History for Visual Studio +**/.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +**/.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp diff --git a/BetterStack.Logs.NLog.csproj b/BetterStack.Logs.NLog.csproj index 76eb401..412396e 100644 --- a/BetterStack.Logs.NLog.csproj +++ b/BetterStack.Logs.NLog.csproj @@ -1,4 +1,4 @@ - + BetterStack.Logs.NLog 1.0.0 @@ -15,7 +15,7 @@ - netstandard2.0;netstandard2.1 + net462;netstandard2.0;netstandard2.1 $(DefaultItemExcludes);example-project/**;dashboard.png @@ -32,17 +32,20 @@ snupkg - + - - + + + + + - - + + diff --git a/BetterStack.Logs.NLog/BetterStackJsonLayout.cs b/BetterStack.Logs.NLog/BetterStackJsonLayout.cs new file mode 100644 index 0000000..04baf40 --- /dev/null +++ b/BetterStack.Logs.NLog/BetterStackJsonLayout.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Text; +using NLog.Config; +using NLog.Layouts; + +namespace BetterStack.Logs.NLog +{ + internal class BetterStackJsonLayout : JsonLayout + { + /// + /// Gets or sets the layout used to format log messages. + /// + /// + /// The default value of the layout is: ${message} + /// + public Layout Message + { + get => ResolveJsonAttribute(nameof(Message))?.Layout; + set + { + var attribute = ResolveJsonAttribute(nameof(Message)); + if (attribute != null && value != null) + attribute.Layout = value; + } + } + /// + public new bool IncludeEventProperties + { + get + { + var attribute = ResolveJsonAttribute(nameof(Context)); + var propertiesLayout = ResolveJsonAttribute("properties", attribute.Layout as JsonLayout)?.Layout as JsonLayout; + return propertiesLayout?.IncludeEventProperties ?? false; + } + set + { + var attribute = ResolveJsonAttribute(nameof(Context)); + var propertiesLayout = ResolveJsonAttribute("properties", attribute.Layout as JsonLayout)?.Layout as JsonLayout; + if (propertiesLayout != null) + propertiesLayout.IncludeEventProperties = value; + } + } + + /// + public new bool IncludeScopeProperties + { + get => (ResolveJsonAttribute(nameof(Context))?.Layout as JsonLayout)?.IncludeScopeProperties ?? false; + set + { + var attribute = ResolveJsonAttribute(nameof(Context)); + if (attribute != null && attribute.Layout is JsonLayout jsonLayout) + jsonLayout.IncludeScopeProperties = value; + } + } + + /// + public bool IncludeGdcProperties + { + get => (ResolveJsonAttribute(nameof(Context))?.Layout as JsonLayout)?.IncludeGdc ?? false; + set + { + var attribute = ResolveJsonAttribute(nameof(Context)); + if (attribute != null && attribute.Layout is JsonLayout jsonLayout) + jsonLayout.IncludeGdc = value; + } + } + + /// + /// Gets the array of attributes for the "properties"-section + /// + [ArrayParameter(typeof(JsonAttribute), "ContextProperty")] + public IList Context + { + get + { + var attribute = ResolveJsonAttribute(nameof(Context)); + return (attribute?.Layout as JsonLayout)?.Attributes; + } + } + + /// + /// Control callsite capture of source-file and source-linenumber. + /// + /// Enabling this will hurt application performance as NLog will capture StackTrace for each log-message + public StackTraceUsage StackTraceUsage + { + get + { + var attribute = ResolveJsonAttribute(nameof(Context)); + var runtimeLayout = ResolveJsonAttribute("runtime", attribute.Layout as JsonLayout)?.Layout as JsonLayout; + return runtimeLayout?.Attributes?.Count > 0 ? StackTraceUsage.WithCallSite : StackTraceUsage.None; + } + set + { + var attribute = ResolveJsonAttribute(nameof(Context)); + var runtimeLayout = ResolveJsonAttribute("runtime", attribute.Layout as JsonLayout)?.Layout as JsonLayout; + if (value == StackTraceUsage.None && runtimeLayout != null) + { + runtimeLayout.Attributes.Clear(); + } + else if (value != StackTraceUsage.None && runtimeLayout != null) + { + runtimeLayout.Attributes.Clear(); + runtimeLayout.Attributes.Add(new JsonAttribute("class", "${callsite:classname=true:methodName=false}")); + runtimeLayout.Attributes.Add(new JsonAttribute("member", "${callsite:classname=false:methodName=true}")); + if (value.HasFlag(StackTraceUsage.WithFileNameAndLineNumber) || value.HasFlag(StackTraceUsage.WithStackTrace)) + { + runtimeLayout.Attributes.Add(new JsonAttribute("file", "${callsite-filename}")); + runtimeLayout.Attributes.Add(new JsonAttribute("line", "${callsite-linenumber}")); + } + } + } + } + + public BetterStackJsonLayout() + { + Attributes.Add(new JsonAttribute("dt", "\"${date:universalTime=true:format=o}\"", encode: false)); + Attributes.Add(new JsonAttribute("level", "${level}")); + Attributes.Add(new JsonAttribute("message", "${message}")); + Attributes.Add(new JsonAttribute("logger", "${logger}")); + Attributes.Add(new JsonAttribute("exception", "${exception:format=tostring}")); + var contextLayout = new JsonLayout() { SuppressSpaces = true }; + contextLayout.Attributes.Add(new JsonAttribute("properties", new JsonLayout() { IncludeEventProperties = true, SuppressSpaces = true }, encode: false)); + contextLayout.Attributes.Add(new JsonAttribute("runtime", new JsonLayout() { SuppressSpaces = true }, encode: false)); + Attributes.Add(new JsonAttribute("context", contextLayout, encode: false)); + SuppressSpaces = true; + } + + protected override void InitializeLayout() + { + base.IncludeEventProperties = false; + base.IncludeScopeProperties = false; + base.InitializeLayout(); + } + + private JsonAttribute ResolveJsonAttribute(string attributeName, JsonLayout jsonLayout = null) + { + var attributes = jsonLayout?.Attributes ?? Attributes; + for (int i = 0; i < attributes.Count; ++i) + { + if (attributeName.Equals(attributes[i].Name, StringComparison.OrdinalIgnoreCase)) + { + return attributes[i]; + } + } + return null; + } + } +} diff --git a/BetterStack.Logs.NLog/BetterStackLogsTarget.cs b/BetterStack.Logs.NLog/BetterStackLogsTarget.cs index 723c309..f312711 100644 --- a/BetterStack.Logs.NLog/BetterStackLogsTarget.cs +++ b/BetterStack.Logs.NLog/BetterStackLogsTarget.cs @@ -4,6 +4,7 @@ using NLog.Config; using NLog.Targets; using NLog.Layouts; +using NLog.Common; namespace BetterStack.Logs.NLog { @@ -12,13 +13,26 @@ namespace BetterStack.Logs.NLog /// to the Better Stack server but it sends them periodically in batches. /// [Target("BetterStack.Logs")] - public sealed class BetterStackLogsTarget : TargetWithContext + public sealed class BetterStackLogsTarget : TargetWithLayout { + private readonly BetterStackJsonLayout _jsonLayout = new BetterStackJsonLayout(); + private Drain betterStackDrain = null; + + /// + /// Gets the JSON layout configuration used for formatting log entries. + /// + public JsonLayout JsonLayoutt => _jsonLayout; + + /// + public override Layout Layout + { + get => _jsonLayout.Message; + set => _jsonLayout.Message = value; + } + /// /// Gets or sets the Better Stack Logs source token. /// - /// The source token. - [RequiredParameter] public Layout SourceToken { get; set; } /// @@ -41,52 +55,55 @@ public sealed class BetterStackLogsTarget : TargetWithContext /// public int Retries { get; set; } = 10; + /// + public bool IncludeEventProperties + { + get => _jsonLayout.IncludeEventProperties; + set => _jsonLayout.IncludeEventProperties = value; + } + + /// + public bool IncludeScopeProperties + { + get => _jsonLayout.IncludeScopeProperties; + set => _jsonLayout.IncludeScopeProperties = value; + } + + /// + public bool IncludeGlobalDiagnosticContext + { + get => _jsonLayout.IncludeGdcProperties; + set => _jsonLayout.IncludeGdcProperties = value; + } + /// - /// We capture the file and line of every log message by default. You can turn this - /// option off if it has negative impact on the performance of your application. + /// Capture the file and line of every log-message. /// + /// Enabling this will hurt application performance as NLog will capture StackTrace for each log-message public bool CaptureSourceLocation { - get => StackTraceUsage == StackTraceUsage.Max; + get => StackTraceUsage != StackTraceUsage.None; set => StackTraceUsage = value ? StackTraceUsage.Max : StackTraceUsage.None; } - /// - /// Include GlobalDiagnosticContext in logs. - /// - public bool IncludeGlobalDiagnosticContext { get; set; } = true; - - /// - /// Control callsite capture of source-file and source-linenumber. - /// + /// public StackTraceUsage StackTraceUsage { - get => _stackTraceUsage; - set - { - if (value == StackTraceUsage.None) - { - IncludeCallSite = false; - IncludeCallSiteStackTrace = false; - } - else - { - IncludeCallSite = true; - IncludeCallSiteStackTrace = value == StackTraceUsage.Max; - } - _stackTraceUsage = value; - } + get => _jsonLayout.StackTraceUsage; + set => _jsonLayout.StackTraceUsage = value; } - private StackTraceUsage _stackTraceUsage; - private Drain betterStackDrain = null; + /// + /// To replicate + [ArrayParameter(typeof(JsonAttribute), "contextproperty")] + public IList ContextProperties => _jsonLayout.Context; /// /// Initializes a new instance of the BetterStack.Logs.NLog.BetterStackLogsTarget class. /// public BetterStackLogsTarget() { - StackTraceUsage = StackTraceUsage.Max; + base.Layout = _jsonLayout; } /// @@ -95,7 +112,12 @@ protected override void InitializeTarget() betterStackDrain?.Stop().Wait(); var sourceToken = RenderLogEvent(SourceToken, LogEventInfo.CreateNullEvent()); + if (string.IsNullOrEmpty(sourceToken)) + throw new NLogConfigurationException("SourceToken is required for BetterStackLogsTarget."); + var endpoint = RenderLogEvent(Endpoint, LogEventInfo.CreateNullEvent()); + if (string.IsNullOrEmpty(endpoint)) + throw new NLogConfigurationException("Endpoint is required for BetterStackLogsTarget."); var client = new Client( sourceToken, @@ -115,48 +137,26 @@ protected override void InitializeTarget() /// protected override void CloseTarget() { - betterStackDrain?.Stop().Wait(); + if (betterStackDrain != null && !betterStackDrain.Stop().Wait(TimeSpan.FromSeconds(15))) + global::NLog.Common.InternalLogger.Warn("BetterStackLogsTarget: Failed to Stop. Check for network connectivity issues."); base.CloseTarget(); } /// protected override void Write(LogEventInfo logEvent) { - var contextDictionary = new Dictionary { - ["logger"] = logEvent.LoggerName, - ["properties"] = logEvent.Properties, - ["runtime"] = new Dictionary { - ["class"] = logEvent.CallerClassName, - ["member"] = logEvent.CallerMemberName, - ["file"] = string.IsNullOrEmpty(logEvent.CallerFilePath) ? null : logEvent.CallerFilePath, - ["line"] = string.IsNullOrEmpty(logEvent.CallerFilePath) ? null : logEvent.CallerLineNumber as int?, - }, - }; - - if (IncludeGlobalDiagnosticContext) { - var gdcKeys = GlobalDiagnosticsContext.GetNames(); - - if (gdcKeys.Count > 0) { - var gdcDict = new Dictionary(); - - foreach (string key in gdcKeys) { - if (string.IsNullOrEmpty(key)) continue; - gdcDict[key] = GlobalDiagnosticsContext.GetObject(key); - } - - contextDictionary["gdc"] = gdcDict; - } - } - string logMessage = RenderLogEvent(this.Layout, logEvent); - - var log = new Log { - Timestamp = new DateTimeOffset(logEvent.TimeStamp), - Message = logMessage, - Level = logEvent.Level.Name, - Context = contextDictionary - }; - - betterStackDrain.Enqueue(log); + var payload = RenderLogEvent(_jsonLayout, logEvent); + if (!string.IsNullOrEmpty(payload)) + betterStackDrain.Enqueue(payload); + } + + /// + protected override void FlushAsync(AsyncContinuation asyncContinuation) + { + if (betterStackDrain != null) + betterStackDrain.Flush().ContinueWith(t => asyncContinuation(t.Exception)); + else + base.FlushAsync(asyncContinuation); } } } diff --git a/BetterStack.Logs.NLog/ColorValueFormatter.cs b/BetterStack.Logs.NLog/ColorValueFormatter.cs index 988b9ea..daf0598 100644 --- a/BetterStack.Logs.NLog/ColorValueFormatter.cs +++ b/BetterStack.Logs.NLog/ColorValueFormatter.cs @@ -10,7 +10,7 @@ namespace BetterStack.Logs.NLog { public class ColorValueFormatter : IValueFormatter { - IValueFormatter valueFormatter; + private readonly IValueFormatter valueFormatter; private const string AnsiRed = "\x1b[31;1m"; private const string AnsiGreen = "\x1b[32;1m"; @@ -22,7 +22,7 @@ public class ColorValueFormatter : IValueFormatter public ColorValueFormatter(IValueFormatter valueFormatter = null) { - this.valueFormatter = valueFormatter != null ? valueFormatter : ConfigurationItemFactory.Default.ValueFormatter; + this.valueFormatter = valueFormatter != null ? valueFormatter : global::NLog.LogManager.LogFactory.ServiceRepository.GetService(typeof(IValueFormatter)) as IValueFormatter; } public bool FormatValue( diff --git a/BetterStack.Logs/Client.cs b/BetterStack.Logs/Client.cs index d4b0dbd..2362fa6 100644 --- a/BetterStack.Logs/Client.cs +++ b/BetterStack.Logs/Client.cs @@ -1,10 +1,9 @@ using System; using System.Collections.Generic; +using System.Net; using System.Net.Http; using System.Text; using System.Threading.Tasks; -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; namespace BetterStack.Logs { @@ -14,13 +13,10 @@ namespace BetterStack.Logs public sealed class Client { private readonly HttpClient httpClient; - private readonly JsonSerializerSettings settings = new JsonSerializerSettings { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - ContractResolver = new DefaultContractResolver { - NamingStrategy = new CamelCaseNamingStrategy() - } - }; private readonly int retries; + private readonly StringBuilder _payloadBuilder = new StringBuilder(); + private readonly System.Net.Http.Headers.MediaTypeHeaderValue _contentTypeJson = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + private readonly char[] _reusableEncodingBuffer = new char[40 * 1024]; // Avoid Large-Object-Heap (LOH) public Client( string sourceToken, @@ -29,19 +25,13 @@ public Client( int retries = 10 ) { - settings.Converters.Add(new Newtonsoft.Json.Converters.StringEnumConverter()); - settings.Converters.Add(new ToStringJsonConverter(typeof(System.Reflection.MemberInfo))); - settings.Converters.Add(new ToStringJsonConverter(typeof(System.Reflection.Assembly))); - settings.Converters.Add(new ToStringJsonConverter(typeof(System.Reflection.Module))); - settings.Error = (sender, args) => - { - args.ErrorContext.Handled = true; // Ignore Properties that throws Exceptions - }; - httpClient = new HttpClient(); httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {sourceToken}"); httpClient.BaseAddress = new Uri(endpoint); httpClient.Timeout = timeout ?? TimeSpan.FromSeconds(10); +#if NETFRAMEWORK + httpClient.DefaultRequestHeaders.ExpectContinue = false; // Avoid 100-Continue delay on .NET Framework. Default turned off for .NET Core and later +#endif this.retries = retries; } @@ -50,75 +40,96 @@ public Client( /// Sends a collection of logs to the server with several retries /// if an error occures. /// - public async Task Send(IEnumerable logs) + public async Task Send(List logs) { var content = serialize(logs); + logs.Clear(); // Allow garbage collection, while waiting for the request to complete for (int i = 0; i < retries; ++i) { - await Task.Delay(TimeSpan.FromSeconds(i)); - var success = await sendOnce(content); if (success) break; + + await Task.Delay(TimeSpan.FromSeconds(i)); } } private async Task sendOnce(HttpContent content) { - try { - var response = await httpClient.PostAsync("/", content); - return response.IsSuccessStatusCode; - } catch (TaskCanceledException) { - // request timed out, silent error - } catch (HttpRequestException) { - // TODO: repeat only for certain HTTP errors (429, 5xx) - // some networking error, silent error + var httpStatusCode = default(HttpStatusCode); + try + { + using (var response = await httpClient.PostAsync("/", content)) + { + httpStatusCode = response.StatusCode; + response.EnsureSuccessStatusCode(); // Throw if not a success code + return true; + } } + catch (HttpRequestException ex) + { + global::NLog.Common.InternalLogger.Warn(ex, "BetterStackLogsTarget: HTTP request failed with status code {0}", (int)httpStatusCode); - return false; - } +#if NET || NETSTANDARD2_1_OR_GREATER + if (httpStatusCode == HttpStatusCode.TooManyRequests || httpStatusCode == HttpStatusCode.RequestTimeout || ((int)httpStatusCode >= 500 && httpStatusCode != HttpStatusCode.NetworkAuthenticationRequired)) +#else + if ((int)httpStatusCode == 429 || httpStatusCode == HttpStatusCode.RequestTimeout || ((int)httpStatusCode >= 500 && (int)httpStatusCode != 511)) +#endif + { + // TODO retry only 429 + 408 + 5xx (server errors, typically transient) + } + } + catch (OperationCanceledException ex) + { + global::NLog.Common.InternalLogger.Warn(ex, "BetterStackLogsTarget: Http Request timed out."); + } + catch (Exception ex) + { + global::NLog.Common.InternalLogger.Warn(ex, "BetterStackLogsTarget: Http Request failed."); + } - private HttpContent serialize(IEnumerable logs) { - var payload = JsonConvert.SerializeObject(logs, settings); - var content = new ByteArrayContent(Encoding.UTF8.GetBytes(payload)); - content.Headers.Add("Content-Type", "application/json"); - return content; + return false; } - /// - /// JSON converter that just calls ToString on the target value (when non-null). - /// This is configured as the converter for types that will otherwise spew a lot of irrelevant JSON - /// into logs. - /// - internal sealed class ToStringJsonConverter : JsonConverter - { - private readonly System.Type _type; - - /// - public override bool CanRead => false; - - public ToStringJsonConverter(System.Type type) => - _type = type; - - /// - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + private HttpContent serialize(List logs) { + lock (_payloadBuilder) { - if (value is null) + try { - writer.WriteNull(); + _payloadBuilder.Length = 0; + _payloadBuilder.Append('['); + foreach (var log in logs) + { + if (_payloadBuilder.Length > 1) + _payloadBuilder.Append(','); + _payloadBuilder.Append(log); + } + _payloadBuilder.Append(']'); + var content = new ByteArrayContent(EncodePayload(Encoding.UTF8, _payloadBuilder)); + content.Headers.ContentType = _contentTypeJson; + return content; } - else + finally { - writer.WriteValue(value.ToString()); + if (_payloadBuilder.Length > _reusableEncodingBuffer.Length) + _payloadBuilder.Remove(0, _payloadBuilder.Length - 1); // Attempt soft clear that skips Large-Object-Heap (LOH) re-allocation + _payloadBuilder.Length = 0; } } + } - /// - public override object ReadJson(JsonReader reader, System.Type objectType, object existingValue, JsonSerializer serializer) => - throw new NotSupportedException("Only serialization is supported"); + byte[] EncodePayload(Encoding encoder, StringBuilder payload) + { + lock (_reusableEncodingBuffer) + { + var payloadLength = payload.Length; + if (payloadLength < _reusableEncodingBuffer.Length) + { + payload.CopyTo(0, _reusableEncodingBuffer, 0, payloadLength); + return encoder.GetBytes(_reusableEncodingBuffer, 0, payloadLength); + } - /// - public override bool CanConvert(System.Type objectType) => - _type.IsAssignableFrom(objectType); + return encoder.GetBytes(payload.ToString()); + } } } } diff --git a/BetterStack.Logs/Drain.cs b/BetterStack.Logs/Drain.cs index 849be5c..298a4fa 100644 --- a/BetterStack.Logs/Drain.cs +++ b/BetterStack.Logs/Drain.cs @@ -17,11 +17,11 @@ public sealed class Drain private readonly Client client; private readonly TimeSpan period; - private object taskLock = new object(); private readonly Task runningTask; + private volatile Task sendTask = Task.CompletedTask; - private ConcurrentQueue queue = new ConcurrentQueue(); - private CancellationTokenSource cancellationTokenSource; + private readonly ConcurrentQueue queue = new ConcurrentQueue(); + private readonly CancellationTokenSource cancellationTokenSource; /// /// Initializes a Better Stack Logs drain and starts periodic logs delivery. @@ -45,7 +45,7 @@ public Drain( /// Adds a single log event to a queue. The log event will be delivered later in a batch. /// This method will throw an exception if the Drain is stopped. /// - public void Enqueue(Log log) + public void Enqueue(string log) { if (cancellationTokenSource.IsCancellationRequested) throw new DrainIsClosedException(); @@ -61,47 +61,52 @@ public async Task Stop() await runningTask; } + /// + /// Waits for the pending queue to be flushed. + /// + public async Task Flush() + { + for (int i = 0; i < 3; ++i) + { + if (queue.IsEmpty) + break; + await Task.Delay(period); + await sendTask; + } + await Task.Delay(period); + await sendTask; + } + private async Task run() { var nextDelay = period; + var nextBatch = new List(); + // XXX: We want the loop to run at least once, even if we stop // the drain before the we manage to reach this point. do { - var flushDuration = await delayed(flush, nextDelay); - nextDelay = period - flushDuration; - } while (!cancellationTokenSource.IsCancellationRequested); - } - - private async Task flush() { - while (!queue.IsEmpty) { - var expectedItemsCount = Math.Min(maxBatchSize, queue.Count); - var nextBatch = new List(expectedItemsCount); - - while (!queue.IsEmpty && nextBatch.Count < maxBatchSize) { - if (queue.TryDequeue(out var log)) nextBatch.Add(log); + if (nextDelay > TimeSpan.Zero) { + try { + await Task.Delay(nextDelay, cancellationTokenSource.Token); + } catch (OperationCanceledException) { + // finish the rest of the loop to flush everything + } } - if (nextBatch.Count > 0) { - await client.Send(nextBatch); - } - } - } - - private async Task delayed(Func asyncAction, TimeSpan delay) - { - if (delay < TimeSpan.Zero) delay = TimeSpan.Zero; - - try { - await Task.Delay(delay, cancellationTokenSource.Token); - } catch (TaskCanceledException) { - // finish the rest of the loop to flush everything - } - - var start = DateTimeOffset.UtcNow; - - await asyncAction(); + var start = DateTime.UtcNow; + while (!queue.IsEmpty) + { + nextBatch.Clear(); + while (nextBatch.Count < maxBatchSize && queue.TryDequeue(out var log)) + nextBatch.Add(log); - return DateTimeOffset.UtcNow - start; + sendTask = client.Send(nextBatch); + await sendTask; + sendTask = Task.CompletedTask; + } + var flushDuration = DateTime.UtcNow - start; + nextDelay = period - flushDuration; + } while (!cancellationTokenSource.IsCancellationRequested); } } } diff --git a/BetterStack.Logs/Log.cs b/BetterStack.Logs/Log.cs deleted file mode 100644 index 35983c6..0000000 --- a/BetterStack.Logs/Log.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Collections.Generic; -using Newtonsoft.Json; - -namespace BetterStack.Logs -{ - public sealed class Log - { - [JsonProperty("dt")] - public DateTimeOffset Timestamp { get; set; } - - public string Message { get; set; } - - public string Level { get; set; } - - public Dictionary Context { get; set; } - - public Log() { - Timestamp = DateTimeOffset.UtcNow; - } - } -} diff --git a/example-project/nlog.config b/example-project/nlog.config index d8c731d..9c1307a 100644 --- a/example-project/nlog.config +++ b/example-project/nlog.config @@ -3,6 +3,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" autoReload="true" internalLogLevel="Warn" + internalLogToConsole="true" internalLogFile="internal.txt">