diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..237d960 --- /dev/null +++ b/.gitignore @@ -0,0 +1,183 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Roslyn cache directories +*.ide/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +#NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding addin-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml + +# 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 +# TODO: 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 + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# If using the old MSBuild-Integrated Package Restore, uncomment this: +#!**/packages/repositories.config + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +sql/ +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ + +# RIA/Silverlight projects +Generated_Code/ + +# 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 + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ diff --git a/README.md b/README.md index 5f5aa71..2b73cd0 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,25 @@ A Serilog sink that writes events to the [NewRelic](https://newrelic.com) apm application. -[![Package Logo](http://serilog.net/images/serilog-sink-seq-nuget.png)](http://nuget.org/packages/serilog.sinks.seq) - ## Getting started -To get started install the _Serilog.Sinks.Seq_ package from Visual Studio's _NuGet_ console: +Install NewRelic NuGetPackage + +Configure NewRelic Settings: -```powershell -PM> Install-Package Serilog.Sinks.Seq +```xml + + + + + ``` Point the logger to NewRelic: ```csharp Log.Logger = new LoggerConfiguration() - .WriteTo.NewRelic() + .WriteTo.NewRelic(applicationName: "Serilog.Sinks.NewRelic.Sample") .CreateLogger(); ``` @@ -24,3 +28,19 @@ And use the Serilog logging methods to associate named properties with log event ```csharp Log.Error("Failed to log on user {ContactId}", contactId); ``` + +The sink also supports sending Serilog.Metrics to NewRelic although this requires a custom transaction in NewRelic See [here](https://docs.newrelic.com/docs/agents/net-agent/instrumentation/net-custom-instrumentation) and may turn out to be largely redundant! + +```csharp +// Adding a custom transaction + +using (logger.BeginTimedOperation("Time a thread sleep for 2 seconds.")) +{ + Thread.Sleep(1000); + using (logger.BeginTimedOperation("And inside we try a Task.Delay for 2 seconds.")) + { + Task.Delay(2000).Wait(); + } + Thread.Sleep(1000); +} +``` \ No newline at end of file diff --git a/Serilog.Sinks.NewRelic.Sample/App.config b/Serilog.Sinks.NewRelic.Sample/App.config new file mode 100644 index 0000000..5351b5d --- /dev/null +++ b/Serilog.Sinks.NewRelic.Sample/App.config @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Serilog.Sinks.NewRelic.Sample/Program.cs b/Serilog.Sinks.NewRelic.Sample/Program.cs new file mode 100644 index 0000000..ea81737 --- /dev/null +++ b/Serilog.Sinks.NewRelic.Sample/Program.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Serilog.Enrichers; +using Serilog.Events; + +namespace Serilog.Sinks.NewRelic.Sample +{ + class Program + { + static void Main(string[] args) + { + var logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.ColoredConsole( + outputTemplate: "{Timestamp:HH:mm:ss} ({ThreadId}) [{Level}] {Message}{NewLine}{Exception}") + .WriteTo.Trace() + .WriteTo.NewRelic(applicationName: "Serilog.Sinks.NewRelic.Sample") + .Enrich.With(new ThreadIdEnricher(), new MachineNameEnricher()) + .CreateLogger(); + + logger.Information("This is a simple information message {Property1}",100); + + // Adding a custom transaction + + using (logger.BeginTimedOperation("Time a thread sleep for 2 seconds.")) + { + Thread.Sleep(1000); + using (logger.BeginTimedOperation("And inside we try a Task.Delay for 2 seconds.")) + { + Task.Delay(2000).Wait(); + } + Thread.Sleep(1000); + } + + using (logger.BeginTimedOperation("Using a passed in identifier", "test-loop")) + { + // ReSharper disable once NotAccessedVariable + var a = ""; + for (var i = 0; i < 1000; i++) + { + a += "b"; + } + } + + // Exceed a limit + using (logger.BeginTimedOperation("This should execute within 1 second.", null, LogEventLevel.Debug, TimeSpan.FromSeconds(1))) + { + Thread.Sleep(1100); + } + + // Gauge + + var queue = new Queue(); + var gauge = logger.GaugeOperation("queue", "item(s)", () => queue.Count()); + + gauge.Write(); + + queue.Enqueue(20); + + gauge.Write(); + + queue.Dequeue(); + + gauge.Write(); + + // Counter + var counter = logger.CountOperation("counter", "operation(s)", true, LogEventLevel.Debug, resolution: 2); + counter.Increment(); + counter.Increment(); + counter.Increment(); + counter.Decrement(); + + // Throw Exception + try + { + throw new ApplicationException("This is an exception raised to test the New Relic API"); + } + catch (Exception ex) + { + logger.Error(ex, "Error whilst testing the Serilog.Sinks.NewRelic.Sample"); + } + + + + System.Console.WriteLine("Press a key to exit."); + System.Console.ReadKey(true); + + } + } +} diff --git a/Serilog.Sinks.NewRelic.Sample/Properties/AssemblyInfo.cs b/Serilog.Sinks.NewRelic.Sample/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..fb711c8 --- /dev/null +++ b/Serilog.Sinks.NewRelic.Sample/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Serilog.Sinks.NewRelic.Sample")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Serilog.Sinks.NewRelic.Sample")] +[assembly: AssemblyCopyright("Copyright © 2015")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("569471cf-2fb0-434b-9f8e-58784efb6f78")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Serilog.Sinks.NewRelic.Sample/Serilog.Sinks.NewRelic.Sample.csproj b/Serilog.Sinks.NewRelic.Sample/Serilog.Sinks.NewRelic.Sample.csproj new file mode 100644 index 0000000..268019c --- /dev/null +++ b/Serilog.Sinks.NewRelic.Sample/Serilog.Sinks.NewRelic.Sample.csproj @@ -0,0 +1,94 @@ + + + + + Debug + AnyCPU + {569471CF-2FB0-434B-9F8E-58784EFB6F78} + Exe + Properties + Serilog.Sinks.NewRelic.Sample + Serilog.Sinks.NewRelic.Sample + v4.6 + 512 + true + + + + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\packages\NewRelic.Agent.Api.5.8.28.0\lib\NewRelic.Api.Agent.dll + True + + + ..\packages\NRConfig.Tool.1.5.0.0\lib\NRConfig.dll + True + + + ..\packages\Serilog.1.5.12\lib\net45\Serilog.dll + True + + + ..\packages\Serilog.1.5.12\lib\net45\Serilog.FullNetFx.dll + True + + + ..\packages\SerilogMetrics.1.0.29\lib\net45\SerilogMetrics.dll + True + + + + + + + + + + + + + + + + + + + + + {f34ae60b-cfd5-464c-bf49-a9d8fa7ee424} + Serilog.Sinks.NewRelic + + + + + + + + + \ No newline at end of file diff --git a/Serilog.Sinks.NewRelic.Sample/packages.config b/Serilog.Sinks.NewRelic.Sample/packages.config new file mode 100644 index 0000000..be5f075 --- /dev/null +++ b/Serilog.Sinks.NewRelic.Sample/packages.config @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/Serilog.Sinks.NewRelic/NewRelicLoggerConfigurationExtensions.cs b/Serilog.Sinks.NewRelic/NewRelicLoggerConfigurationExtensions.cs new file mode 100644 index 0000000..370df92 --- /dev/null +++ b/Serilog.Sinks.NewRelic/NewRelicLoggerConfigurationExtensions.cs @@ -0,0 +1,45 @@ +using System; +using Serilog.Configuration; +using Serilog.Core; +using Serilog.Events; +using Serilog.Sinks.NewRelic.Sinks.NewRelic; + +namespace Serilog.Sinks.NewRelic +{ + public static class NewRelicLoggerConfigurationExtensions + { + public static LoggerConfiguration NewRelic( + this LoggerSinkConfiguration loggerSinkConfiguration, + LogEventLevel restrictedToMinimumLevel = LevelAlias.Minimum, + int batchPostingLimit = NewRelicSink.DefaultBatchPostingLimit, + TimeSpan? period = null, + string applicationName = null, + string bufferBaseFilename = null, + long? bufferFileSizeLimitBytes = null) + { + if (loggerSinkConfiguration == null) throw new ArgumentNullException("loggerSinkConfiguration"); + + if (bufferFileSizeLimitBytes.HasValue && bufferFileSizeLimitBytes < 0) + throw new ArgumentException("Negative value provided; file size limit must be non-negative"); + + if (string.IsNullOrEmpty(applicationName)) + throw new ArgumentException("Must supply an application name"); + + var defaultedPeriod = period ?? NewRelicSink.DefaultPeriod; + + ILogEventSink sink; + + if (bufferBaseFilename == null) + sink = new NewRelicSink(applicationName, batchPostingLimit, defaultedPeriod); + else + { + //sink = new DurableNewRelicSink(bufferBaseFilename, applicationName, batchPostingLimit, defaultedPeriod, + // bufferFileSizeLimitBytes); + + throw new NotImplementedException("DurableNewRelicSink is not implemented yet."); + } + + return loggerSinkConfiguration.Sink(sink, restrictedToMinimumLevel); + } + } +} \ No newline at end of file diff --git a/Serilog.Sinks.NewRelic/Properties/AssemblyInfo.cs b/Serilog.Sinks.NewRelic/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..a350cd1 --- /dev/null +++ b/Serilog.Sinks.NewRelic/Properties/AssemblyInfo.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Serilog.Sinks.NewRelic")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Serilog.Sinks.NewRelic")] +[assembly: AssemblyCopyright("Copyright © 2015")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("f34ae60b-cfd5-464c-bf49-a9d8fa7ee424")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Serilog.Sinks.NewRelic/Serilog.Sinks.NewRelic.csproj b/Serilog.Sinks.NewRelic/Serilog.Sinks.NewRelic.csproj new file mode 100644 index 0000000..44cce13 --- /dev/null +++ b/Serilog.Sinks.NewRelic/Serilog.Sinks.NewRelic.csproj @@ -0,0 +1,79 @@ + + + + + Debug + AnyCPU + {F34AE60B-CFD5-464C-BF49-A9D8FA7EE424} + Library + Properties + Serilog.Sinks.NewRelic + Serilog.Sinks.NewRelic + v4.6 + 512 + + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\packages\NewRelic.Agent.Api.5.8.28.0\lib\NewRelic.Api.Agent.dll + True + + + ..\packages\NRConfig.Tool.1.5.0.0\lib\NRConfig.dll + True + + + ..\packages\Serilog.1.5.12\lib\net45\Serilog.dll + True + + + ..\packages\Serilog.1.5.12\lib\net45\Serilog.FullNetFx.dll + True + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Serilog.Sinks.NewRelic/Serilog.Sinks.NewRelic.sln b/Serilog.Sinks.NewRelic/Serilog.Sinks.NewRelic.sln new file mode 100644 index 0000000..003191b --- /dev/null +++ b/Serilog.Sinks.NewRelic/Serilog.Sinks.NewRelic.sln @@ -0,0 +1,33 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 14 +VisualStudioVersion = 14.0.23107.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Serilog.Sinks.NewRelic", "Serilog.Sinks.NewRelic.csproj", "{F34AE60B-CFD5-464C-BF49-A9D8FA7EE424}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Serilog.Sinks.NewRelic.Sample", "..\Serilog.Sinks.NewRelic.Sample\Serilog.Sinks.NewRelic.Sample.csproj", "{569471CF-2FB0-434B-9F8E-58784EFB6F78}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{30EC5940-9B2D-485B-B854-F978C4B8BD5A}" + ProjectSection(SolutionItems) = preProject + ..\README.md = ..\README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F34AE60B-CFD5-464C-BF49-A9D8FA7EE424}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F34AE60B-CFD5-464C-BF49-A9D8FA7EE424}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F34AE60B-CFD5-464C-BF49-A9D8FA7EE424}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F34AE60B-CFD5-464C-BF49-A9D8FA7EE424}.Release|Any CPU.Build.0 = Release|Any CPU + {569471CF-2FB0-434B-9F8E-58784EFB6F78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {569471CF-2FB0-434B-9F8E-58784EFB6F78}.Debug|Any CPU.Build.0 = Debug|Any CPU + {569471CF-2FB0-434B-9F8E-58784EFB6F78}.Release|Any CPU.ActiveCfg = Release|Any CPU + {569471CF-2FB0-434B-9F8E-58784EFB6F78}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/Serilog.Sinks.NewRelic/Sinks/NewRelic/DurableNewRelicSink.cs b/Serilog.Sinks.NewRelic/Sinks/NewRelic/DurableNewRelicSink.cs new file mode 100644 index 0000000..69f29db --- /dev/null +++ b/Serilog.Sinks.NewRelic/Sinks/NewRelic/DurableNewRelicSink.cs @@ -0,0 +1,45 @@ +using System; +using Serilog.Core; +using Serilog.Events; +using Serilog.Sinks.RollingFile; + +namespace Serilog.Sinks.NewRelic.Sinks.NewRelic +{ + internal class DurableNewRelicSink : ILogEventSink, IDisposable + { + private readonly NewRelicLogShipper _shipper; + private readonly RollingFileSink _sink; + private string bufferBaseFilename; + private string applicationName; + private int batchPostingLimit; + private TimeSpan defaultedPeriod; + private long? bufferFileSizeLimitBytes; + + public DurableNewRelicSink(string bufferBaseFilename, string applicationName, int batchPostingLimit, TimeSpan defaultedPeriod, long? bufferFileSizeLimitBytes) + { + this.bufferBaseFilename = bufferBaseFilename; + this.applicationName = applicationName; + this.batchPostingLimit = batchPostingLimit; + this.defaultedPeriod = defaultedPeriod; + this.bufferFileSizeLimitBytes = bufferFileSizeLimitBytes; + } + + public void Emit(LogEvent logEvent) + { + // This is a lagging indicator, but the network bandwidth usage benefits + // are worth the ambiguity. + var minimumAcceptedLevel = _shipper.MinimumAcceptedLevel; + if (minimumAcceptedLevel == null || + (int)minimumAcceptedLevel <= (int)logEvent.Level) + { + _sink.Emit(logEvent); + } + } + + public void Dispose() + { + _sink.Dispose(); + _shipper.Dispose(); + } + } +} \ No newline at end of file diff --git a/Serilog.Sinks.NewRelic/Sinks/NewRelic/LogEventExtensions.cs b/Serilog.Sinks.NewRelic/Sinks/NewRelic/LogEventExtensions.cs new file mode 100644 index 0000000..ca4ba57 --- /dev/null +++ b/Serilog.Sinks.NewRelic/Sinks/NewRelic/LogEventExtensions.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Serilog.Events; + +namespace Serilog.Sinks.NewRelic.Sinks.NewRelic +{ + public static class LogEventExtensions + { + public static bool IsTimerEvent(this LogEvent logEvent) + { + return logEvent.Properties.Any(p => p.Key == "TimedOperationId"); + } + + public static bool IsCounterEvent(this LogEvent logEvent) + { + return logEvent.Properties.Any(p => p.Key == "CounterName"); + } + + public static bool IsGaugeEvent(this LogEvent logEvent) + { + return logEvent.Properties.Any(p => p.Key == "GaugeName"); + } + + public static bool IsTransactionEvent(this LogEvent logEvent) + { + return logEvent.Properties.Any(p => p.Key == "TransactionName"); + } + + public static string ToNewRelicSafeString(this string str, IDictionary reservedWords) + { + return reservedWords.Aggregate(str, + (current, reservedWord) => current.ReplaceCaseInsensitiveFind(reservedWord.Key, reservedWord.Value)); + } + + public static string ReplaceCaseInsensitiveFind(this string str, string currValue,string newValue) + { + var protectedWords = Regex.Replace(str, + "\\b" + Regex.Escape(currValue) + "\\b", + newValue, + RegexOptions.IgnoreCase); + + var safeCharacters = Regex.Replace(protectedWords, @"[^a-zA-Z0-9:_ ]", ""); + + return safeCharacters; + } + } +} \ No newline at end of file diff --git a/Serilog.Sinks.NewRelic/Sinks/NewRelic/NewRelicLogShipper.cs b/Serilog.Sinks.NewRelic/Sinks/NewRelic/NewRelicLogShipper.cs new file mode 100644 index 0000000..2deb8f1 --- /dev/null +++ b/Serilog.Sinks.NewRelic/Sinks/NewRelic/NewRelicLogShipper.cs @@ -0,0 +1,317 @@ +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using Serilog.Debugging; +using Serilog.Events; + +namespace Serilog.Sinks.NewRelic.Sinks.NewRelic +{ + internal class NewRelicLogShipper : IDisposable + { + private readonly string _apiKey; + private readonly int _batchPostingLimit; + private readonly Timer _timer; + private readonly TimeSpan _period; + private readonly object _stateLock = new object(); + + private LogEventLevel? _minimumAcceptedLevel; + private static readonly TimeSpan RequiredLevelCheckInterval = TimeSpan.FromMinutes(2); + private DateTime _nextRequiredLevelCheckUtc = DateTime.UtcNow.Add(RequiredLevelCheckInterval); + + private volatile bool _unloading; + private readonly string _bookmarkFilename; + private readonly string _logFolder; + private readonly string _candidateSearchPath; + + public NewRelicLogShipper(string bufferBaseFilename, string apiKey, int batchPostingLimit, TimeSpan period) + { + _bookmarkFilename = Path.GetFullPath(bufferBaseFilename + ".bookmark"); + _logFolder = Path.GetDirectoryName(_bookmarkFilename); + _candidateSearchPath = Path.GetFileName(bufferBaseFilename) + "*.json"; + _timer = new Timer(s => OnTick()); + _period = period; + + AppDomain.CurrentDomain.DomainUnload += OnAppDomainUnloading; + AppDomain.CurrentDomain.ProcessExit += OnAppDomainUnloading; + + SetTimer(); + } + + private void OnAppDomainUnloading(object sender, EventArgs args) + { + CloseAndFlush(); + } + + private void CloseAndFlush() + { + lock (_stateLock) + { + if (_unloading) + return; + + _unloading = true; + } + + AppDomain.CurrentDomain.DomainUnload -= OnAppDomainUnloading; + AppDomain.CurrentDomain.ProcessExit -= OnAppDomainUnloading; + + var wh = new ManualResetEvent(false); + if (_timer.Dispose(wh)) + wh.WaitOne(); + + OnTick(); + } + + /// + /// Get the last "minimum level" indicated by the Seq server, if any. + /// + public LogEventLevel? MinimumAcceptedLevel + { + get + { + lock (_stateLock) + return _minimumAcceptedLevel; + } + } + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + /// 2 + public void Dispose() + { + Dispose(true); + } + + /// + /// Free resources held by the sink. + /// + /// If true, called because the object is being disposed; if false, + /// the object is being disposed from the finalizer. + protected virtual void Dispose(bool disposing) + { + if (!disposing) return; + CloseAndFlush(); + } + + private void SetTimer() + { + // Note, called under _stateLock + + _timer.Change(_period, Timeout.InfiniteTimeSpan); + } + + private void OnTick() + { + LogEventLevel? minimumAcceptedLevel = null; + + try + { + int count; + do + { + count = 0; + + // Locking the bookmark ensures that though there may be multiple instances of this + // class running, only one will ship logs at a time. + + using ( + var bookmark = File.Open(_bookmarkFilename, FileMode.OpenOrCreate, FileAccess.ReadWrite, + FileShare.Read)) + { + long nextLineBeginsAtOffset; + string currentFile; + + TryReadBookmark(bookmark, out nextLineBeginsAtOffset, out currentFile); + + var fileSet = GetFileSet(); + + if (currentFile == null || !File.Exists(currentFile)) + { + nextLineBeginsAtOffset = 0; + currentFile = fileSet.FirstOrDefault(); + } + + if (currentFile == null) + continue; + + + + var payload = new StringWriter(); + payload.Write("{\"events\":["); + var delimStart = ""; + + using (var current = File.Open(currentFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite) + ) + { + current.Position = nextLineBeginsAtOffset; + + string nextLine; + while (count < _batchPostingLimit && + TryReadLine(current, ref nextLineBeginsAtOffset, out nextLine)) + { + ++count; + payload.Write(delimStart); + payload.Write(nextLine); + delimStart = ","; + } + + payload.Write("]}"); + } + + if (count > 0 || _minimumAcceptedLevel != null && _nextRequiredLevelCheckUtc < DateTime.UtcNow) + { + lock (_stateLock) + { + _nextRequiredLevelCheckUtc = DateTime.UtcNow.Add(RequiredLevelCheckInterval); + } + + if (true) + { + WriteBookmark(bookmark, nextLineBeginsAtOffset, currentFile); + // var returned = result.Content.ReadAsStringAsync().Result; + // minimumAcceptedLevel = SeqApi.ReadEventInputResult(returned); + } + else + { + //SelfLog.WriteLine("Received failed HTTP shipping result {0}: {1}", result.StatusCode, + // result.Content.ReadAsStringAsync().Result); + } + } + else + { + // Only advance the bookmark if no other process has the + // current file locked, and its length is as we found it. + + if (fileSet.Length == 2 && fileSet.First() == currentFile && + IsUnlockedAtLength(currentFile, nextLineBeginsAtOffset)) + { + WriteBookmark(bookmark, 0, fileSet[1]); + } + + if (fileSet.Length > 2) + { + // Once there's a third file waiting to ship, we do our + // best to move on, though a lock on the current file + // will delay this. + + File.Delete(fileSet[0]); + } + } + } + } while (count == _batchPostingLimit); + } + catch (Exception ex) + { + SelfLog.WriteLine("Exception while emitting periodic batch from {0}: {1}", this, ex); + } + finally + { + lock (_stateLock) + { + _minimumAcceptedLevel = minimumAcceptedLevel; + if (!_unloading) + SetTimer(); + } + } + } + + private bool IsUnlockedAtLength(string file, long maxLen) + { + try + { + using (var fileStream = File.Open(file, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read)) + { + return fileStream.Length <= maxLen; + } + } + catch (IOException ex) + { + var errorCode = Marshal.GetHRForException(ex) & ((1 << 16) - 1); + if (errorCode != 32 && errorCode != 33) + { + SelfLog.WriteLine("Unexpected I/O exception while testing locked status of {0}: {1}", file, ex); + } + } + catch (Exception ex) + { + SelfLog.WriteLine("Unexpected exception while testing locked status of {0}: {1}", file, ex); + } + + return false; + } + + private static void WriteBookmark(FileStream bookmark, long nextLineBeginsAtOffset, string currentFile) + { + using (var writer = new StreamWriter(bookmark)) + { + writer.WriteLine("{0}:::{1}", nextLineBeginsAtOffset, currentFile); + } + } + + // It would be ideal to chomp whitespace here, but not required. + private static bool TryReadLine(Stream current, ref long nextStart, out string nextLine) + { + var includesBom = nextStart == 0; + + if (current.Length <= nextStart) + { + nextLine = null; + return false; + } + + current.Position = nextStart; + + using (var reader = new StreamReader(current, Encoding.UTF8, false, 128, true)) + { + nextLine = reader.ReadLine(); + } + + if (nextLine == null) + return false; + + nextStart += Encoding.UTF8.GetByteCount(nextLine) + Encoding.UTF8.GetByteCount(Environment.NewLine); + if (includesBom) + nextStart += 3; + + return true; + } + + private static void TryReadBookmark(Stream bookmark, out long nextLineBeginsAtOffset, out string currentFile) + { + nextLineBeginsAtOffset = 0; + currentFile = null; + + if (bookmark.Length != 0) + { + string current; + using (var reader = new StreamReader(bookmark, Encoding.UTF8, false, 128, true)) + { + current = reader.ReadLine(); + } + + if (current != null) + { + bookmark.Position = 0; + var parts = current.Split(new[] { ":::" }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 2) + { + nextLineBeginsAtOffset = long.Parse(parts[0]); + currentFile = parts[1]; + } + } + + } + } + + private string[] GetFileSet() + { + return Directory.GetFiles(_logFolder, _candidateSearchPath) + .OrderBy(n => n) + .ToArray(); + } + } +} \ No newline at end of file diff --git a/Serilog.Sinks.NewRelic/Sinks/NewRelic/NewRelicSink.cs b/Serilog.Sinks.NewRelic/Sinks/NewRelic/NewRelicSink.cs new file mode 100644 index 0000000..5ee2113 --- /dev/null +++ b/Serilog.Sinks.NewRelic/Sinks/NewRelic/NewRelicSink.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Serilog.Events; +using Serilog.Sinks.PeriodicBatching; + +namespace Serilog.Sinks.NewRelic.Sinks.NewRelic +{ + internal class NewRelicSink : PeriodicBatchingSink + { + LogEventLevel? _minimumAcceptedLevel; + + static readonly TimeSpan RequiredLevelCheckInterval = TimeSpan.FromMinutes(2); + DateTime _nextRequiredLevelCheckUtc = DateTime.UtcNow.Add(RequiredLevelCheckInterval); + + public const int DefaultBatchPostingLimit = 1000; + public static readonly TimeSpan DefaultPeriod = TimeSpan.FromSeconds(2); + private readonly int _batchPostingLimit; + private readonly TimeSpan _defaultedPeriod; + private readonly IFormatProvider _formatProvider; + private readonly IDictionary _reservedWords; + + public NewRelicSink(string applicationName, int batchSizeLimit, TimeSpan period, IFormatProvider formatProvider = null) : base(batchSizeLimit, period) + { + this._batchPostingLimit = batchSizeLimit; + this._defaultedPeriod = period; + this._formatProvider = formatProvider; + + global::NewRelic.Api.Agent.NewRelic.SetApplicationName(applicationName); + + _reservedWords = PopulateReservedWords(); + } + + private IDictionary PopulateReservedWords() + { + var reservedWords = new Dictionary(); + + reservedWords.Add("add","`add`"); + reservedWords.Add("ago","`ago`"); + reservedWords.Add("and", "`and`"); + reservedWords.Add("as", "`as`"); + reservedWords.Add("auto", "`auto`"); + reservedWords.Add("begin", "`begin`"); + reservedWords.Add("begintime", "`begintime`"); + reservedWords.Add("compare", "`compare`"); + reservedWords.Add("day", "`day`"); + reservedWords.Add("days", "`days`"); + reservedWords.Add("end", "`end`"); + reservedWords.Add("endtime", "`endtime`"); + reservedWords.Add("explain", "`explain`"); + reservedWords.Add("facet", "`facet`"); + reservedWords.Add("from", "`from`"); + reservedWords.Add("hour", "`hour`"); + reservedWords.Add("hours", "`hours`"); + reservedWords.Add("in", "`in`"); + reservedWords.Add("is", "`is`"); + reservedWords.Add("like", "`like`"); + reservedWords.Add("limit", "`limit`"); + reservedWords.Add("minute", "`minute`"); + reservedWords.Add("minutes", "`minutes`"); + reservedWords.Add("month", "`month`"); + reservedWords.Add("months", "`months`"); + reservedWords.Add("not", "`not`"); + reservedWords.Add("null", "`null`"); + reservedWords.Add("offset", "`offset`"); + reservedWords.Add("or", "`or`"); + reservedWords.Add("second", "`second`"); + reservedWords.Add("seconds", "`seconds`"); + reservedWords.Add("select", "`select`"); + reservedWords.Add("since", "`since`"); + reservedWords.Add("timeseries", "`timeseries`"); + reservedWords.Add("until", "`until`"); + reservedWords.Add("week", "`week`"); + reservedWords.Add("weeks", "`weeks`"); + reservedWords.Add("where", "`where`"); + reservedWords.Add("with", "`with`"); + + + return reservedWords; + } + + protected override Task EmitBatchAsync(IEnumerable events) + { + _nextRequiredLevelCheckUtc = DateTime.UtcNow.Add(RequiredLevelCheckInterval); + + //TODO: See if there's a way to determine the level of events accepted into NewRelic for now assume all are. + + _minimumAcceptedLevel = LogEventLevel.Verbose; + + return Task.Run(() => + { + foreach (var logEvent in events) + { + var renderedMessage = logEvent.RenderMessage(_formatProvider).ToNewRelicSafeString(_reservedWords); + + // Made up standard for transactions Property = TransactionName, Value = category::name + + if (logEvent.IsTransactionEvent()) + { + + var transaction = logEvent.Properties.First(x => x.Key == "TransactionName"); + var transactionValue = transaction.Value.ToString().Replace("\"",""); + var transactionValues = transactionValue.Split(new[] { "::" }, StringSplitOptions.None); + + if (transactionValues.Length < 2) + { + continue; + } + + var category = transactionValues[0].ToNewRelicSafeString(_reservedWords); + var name = transactionValues[1].ToNewRelicSafeString(_reservedWords); + + global::NewRelic.Api.Agent.NewRelic.SetTransactionName(category, name); + } + + if (logEvent.IsTimerEvent()) + { + // Ignore the Beginning Operation + + if (logEvent.Properties.All(x => x.Key != "TimedOperationElapsedInMs")) + { + continue; + } + + var elapsedTime = logEvent.Properties.First(x => x.Key == "TimedOperationElapsedInMs"); + var operation = logEvent.Properties.First(x => x.Key == "TimedOperationDescription"); + + int numeric; + var isNumber = int.TryParse(elapsedTime.Value.ToString(), out numeric); + + if (isNumber) + { + var safeOperationString = operation.ToString().ToNewRelicSafeString(_reservedWords); + + global::NewRelic.Api.Agent.NewRelic.RecordResponseTimeMetric(safeOperationString, numeric); + } + + continue; + } + + if (logEvent.IsCounterEvent()) + { + var operation = logEvent.Properties.First(x => x.Key == "CounterName"); + + var safeOperationString = operation.ToString().ToNewRelicSafeString(_reservedWords); + + global::NewRelic.Api.Agent.NewRelic.IncrementCounter(safeOperationString); + + continue; + } + + if (logEvent.IsGaugeEvent()) + { + var elapsedTime = logEvent.Properties.First(x => x.Key == "GaugeValue"); + var operation = logEvent.Properties.First(x => x.Key == "GaugeName"); + + float numeric; + var isNumber = float.TryParse(elapsedTime.Value.ToString(), out numeric); + + if (isNumber) + { + var safeOperationString = operation.ToString().ToNewRelicSafeString(_reservedWords); + + global::NewRelic.Api.Agent.NewRelic.RecordMetric(safeOperationString, numeric); + } + + continue; + } + + if (logEvent.Level == LogEventLevel.Error) + { + var properties = LogEventPropertiesToNewRelicExceptionProperties(logEvent); + + if (logEvent.Exception != null) + { + global::NewRelic.Api.Agent.NewRelic.NoticeError(logEvent.Exception, properties); + } + else + { + global::NewRelic.Api.Agent.NewRelic.NoticeError(renderedMessage, properties); + } + } + else + { + var properties = LogEventPropertiesToNewRelicCustomEventProperties(logEvent); + + global::NewRelic.Api.Agent.NewRelic.RecordCustomEvent(renderedMessage, properties); + } + } + }); + } + + + + // The sink must emit at least one event on startup, and the server be + // configured to set a specific level, before background level checks will be performed. + protected override void OnEmptyBatch() + { + if (_minimumAcceptedLevel != null && + _nextRequiredLevelCheckUtc < DateTime.UtcNow) + { + EmitBatch(Enumerable.Empty()); + } + } + protected override bool CanInclude(LogEvent evt) + { + return _minimumAcceptedLevel == null || + (int)_minimumAcceptedLevel <= (int)evt.Level; + } + private IDictionary LogEventPropertiesToNewRelicExceptionProperties(LogEvent logEvent) + { + var properties = new Dictionary(); + + foreach (var source in logEvent.Properties.Where(p => p.Value != null)) + { + var renderedProperty = source.Value.ToString().ToNewRelicSafeString(_reservedWords); + + properties.Add(source.Key.ToNewRelicSafeString(_reservedWords), renderedProperty); + } + + return properties; + } + private IDictionary LogEventPropertiesToNewRelicCustomEventProperties(LogEvent logEvent) + { + var properties = new Dictionary(); + + foreach (var source in logEvent.Properties.Where(p => p.Value != null)) + { + double numeric; + var isNumber = double.TryParse(source.Value.ToString(), out numeric); + + var safeKey = source.Key.ToNewRelicSafeString(_reservedWords); + + if (!isNumber) + { + var renderedProperty = source.Value.ToString().ToNewRelicSafeString(_reservedWords); + + properties.Add(safeKey, renderedProperty); + } + else + { + properties.Add(safeKey, (float)numeric); + } + } + + return properties; + } + } +} \ No newline at end of file diff --git a/Serilog.Sinks.NewRelic/app.config b/Serilog.Sinks.NewRelic/app.config new file mode 100644 index 0000000..920431d --- /dev/null +++ b/Serilog.Sinks.NewRelic/app.config @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Serilog.Sinks.NewRelic/packages.config b/Serilog.Sinks.NewRelic/packages.config new file mode 100644 index 0000000..760c2ef --- /dev/null +++ b/Serilog.Sinks.NewRelic/packages.config @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file