diff --git a/Sentry.sln b/Sentry.sln index b9fb172df7..e7438b8849 100644 --- a/Sentry.sln +++ b/Sentry.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 VisualStudioVersion = 15.0.27705.0 @@ -71,7 +71,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sentry.Log4Net", "src\Sentr EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sentry.Log4Net.Tests", "test\Sentry.Log4Net.Tests\Sentry.Log4Net.Tests.csproj", "{F275C86B-8F62-4070-9544-83CC5B68B751}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Samples.GenericHost", "samples\Sentry.Samples.GenericHost\Sentry.Samples.GenericHost.csproj", "{5CBEFF17-71BF-407B-868F-C784E6385DC8}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sentry.Samples.GenericHost", "samples\Sentry.Samples.GenericHost\Sentry.Samples.GenericHost.csproj", "{5CBEFF17-71BF-407B-868F-C784E6385DC8}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sentry.Serilog", "src\Sentry.Serilog\Sentry.Serilog.csproj", "{2CF1FBEF-93C4-404E-94F6-543D08842633}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sentry.Serilog.Tests", "test\Sentry.Serilog.Tests\Sentry.Serilog.Tests.csproj", "{EBABB411-4481-478B-BEAD-009D1EE6D259}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sentry.Samples.Serilog", "samples\Sentry.Samples.Serilog\Sentry.Samples.Serilog.csproj", "{B7EDB922-4024-4546-B6E4-E5AB9016369F}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -147,6 +153,18 @@ Global {5CBEFF17-71BF-407B-868F-C784E6385DC8}.Debug|Any CPU.Build.0 = Debug|Any CPU {5CBEFF17-71BF-407B-868F-C784E6385DC8}.Release|Any CPU.ActiveCfg = Release|Any CPU {5CBEFF17-71BF-407B-868F-C784E6385DC8}.Release|Any CPU.Build.0 = Release|Any CPU + {2CF1FBEF-93C4-404E-94F6-543D08842633}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2CF1FBEF-93C4-404E-94F6-543D08842633}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2CF1FBEF-93C4-404E-94F6-543D08842633}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2CF1FBEF-93C4-404E-94F6-543D08842633}.Release|Any CPU.Build.0 = Release|Any CPU + {EBABB411-4481-478B-BEAD-009D1EE6D259}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EBABB411-4481-478B-BEAD-009D1EE6D259}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EBABB411-4481-478B-BEAD-009D1EE6D259}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EBABB411-4481-478B-BEAD-009D1EE6D259}.Release|Any CPU.Build.0 = Release|Any CPU + {B7EDB922-4024-4546-B6E4-E5AB9016369F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B7EDB922-4024-4546-B6E4-E5AB9016369F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7EDB922-4024-4546-B6E4-E5AB9016369F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B7EDB922-4024-4546-B6E4-E5AB9016369F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -169,6 +187,9 @@ Global {D1D3BD5F-0C3A-4DA4-BAA2-2E725E74176F} = {AF6AF4C7-8AA2-4D59-8064-2D79560904EB} {F275C86B-8F62-4070-9544-83CC5B68B751} = {83263231-1A2A-4733-B759-EEFF14E8C5D5} {5CBEFF17-71BF-407B-868F-C784E6385DC8} = {77454495-55EE-4B40-A089-71B9E8F82E89} + {2CF1FBEF-93C4-404E-94F6-543D08842633} = {AF6AF4C7-8AA2-4D59-8064-2D79560904EB} + {EBABB411-4481-478B-BEAD-009D1EE6D259} = {83263231-1A2A-4733-B759-EEFF14E8C5D5} + {B7EDB922-4024-4546-B6E4-E5AB9016369F} = {77454495-55EE-4B40-A089-71B9E8F82E89} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0C652B1A-DF72-4EE5-A98B-194FE2C054F6} diff --git a/samples/Sentry.Samples.Serilog/Program.cs b/samples/Sentry.Samples.Serilog/Program.cs new file mode 100644 index 0000000000..50e22a3e40 --- /dev/null +++ b/samples/Sentry.Samples.Serilog/Program.cs @@ -0,0 +1,50 @@ +using System; +using Sentry.Serilog; +using Serilog; +using Serilog.Context; +using Serilog.Events; + +internal class Program +{ + private static void Main() + { + Log.Logger = new LoggerConfiguration() + .Enrich.FromLogContext() + .MinimumLevel.Debug() + .WriteTo.Console() + .WriteTo.Sentry("https://5fd7a6cda8444965bade9ccfd3df9882@sentry.io/1188141", restrictedToMinimumLevel: LogEventLevel.Information) + .CreateLogger(); + + // The following anonymous object gets serialized and sent with log messages + using (LogContext.PushProperty("inventory", new + { + SmallPotion = 3, + BigPotion = 0, + CheeseWheels = 512 + })) + { + // Logger config enables the Sink only for level INFO or higher so the Debug + // Does not result in an event in Sentry + Log.Debug("Debug message which is not sent."); + + try + { + DoWork(); + } + catch (Exception e) + { + e.Data.Add("details", "Do work always throws."); + Log.Error(e, "Error: with exception"); + } + } + + Log.CloseAndFlush(); + } + + private static void DoWork() + { + Log.Information("About to throw {ExceptionType} type of exception.", nameof(NotImplementedException)); + + throw new NotImplementedException(); + } +} diff --git a/samples/Sentry.Samples.Serilog/Sentry.Samples.Serilog.csproj b/samples/Sentry.Samples.Serilog/Sentry.Samples.Serilog.csproj new file mode 100644 index 0000000000..4fc5b1b7b3 --- /dev/null +++ b/samples/Sentry.Samples.Serilog/Sentry.Samples.Serilog.csproj @@ -0,0 +1,17 @@ + + + + Exe + net462 + 3.5.234 + + + + + + + + + + + diff --git a/src/Sentry.Serilog/Constants.cs b/src/Sentry.Serilog/Constants.cs new file mode 100644 index 0000000000..12d49ffa99 --- /dev/null +++ b/src/Sentry.Serilog/Constants.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Sentry.Serilog +{ + internal static class Constants + { + public const string SdkName = "sentry.dotnet.serilog"; + } +} diff --git a/src/Sentry.Serilog/LevelMapping.cs b/src/Sentry.Serilog/LevelMapping.cs new file mode 100644 index 0000000000..042430ef6f --- /dev/null +++ b/src/Sentry.Serilog/LevelMapping.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Sentry.Protocol; +using Serilog.Events; + +namespace Sentry.Serilog +{ + internal static class LevelMapping + { + public static SentryLevel? ToSentryLevel(this LogEventLevel loggingLevel) + { + switch (loggingLevel) + { + case LogEventLevel.Fatal: + return SentryLevel.Fatal; + case LogEventLevel.Error: + return SentryLevel.Error; + case LogEventLevel.Warning: + return SentryLevel.Warning; + case LogEventLevel.Information: + return SentryLevel.Info; + case LogEventLevel.Debug: + return SentryLevel.Debug; + } + + return null; + } + } +} diff --git a/src/Sentry.Serilog/Properties/AssemblyInfo.cs b/src/Sentry.Serilog/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..4caf74c58c --- /dev/null +++ b/src/Sentry.Serilog/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Sentry.Serilog.Tests,PublicKey=002400000480000094000000060200000024000052534131000400000100010059964a931488bcdbd14657f1ee0df32df61b57b3d14d7290c262c2cc9ddaad6ec984044f761f778e1823049d2cb996a4f58c8ea5b46c37891414cb34b4036b1c178d7b582289d2eef3c0f1e9b692c229a306831ee3d371d9e883f0eb0f74aeac6c6ab8c85fd1ec04b267e15a31532c4b4e2191f5980459db4dce0081f1050fb8")] + +[assembly: CLSCompliant(true)] diff --git a/src/Sentry.Serilog/Sentry.Serilog.csproj b/src/Sentry.Serilog/Sentry.Serilog.csproj new file mode 100644 index 0000000000..c5308f944f --- /dev/null +++ b/src/Sentry.Serilog/Sentry.Serilog.csproj @@ -0,0 +1,20 @@ + + + + netstandard2.0 + $(PackageTags);Logging;Serilog + Sentry.Serilog + Sentry.Serilog + Sentry.Serilog + Official Serilog integration for Sentry - Open-source error tracking that helps developers monitor and fix crashes in real time. + + + + + + + + + + + diff --git a/src/Sentry.Serilog/SentrySink.cs b/src/Sentry.Serilog/SentrySink.cs new file mode 100644 index 0000000000..7d928e1b79 --- /dev/null +++ b/src/Sentry.Serilog/SentrySink.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using Sentry.Extensibility; +using Sentry.Protocol; +using Sentry.Reflection; +using Serilog.Core; +using Serilog.Events; + +namespace Sentry.Serilog +{ + public sealed class SentrySink : ILogEventSink, IDisposable + { + private readonly IFormatProvider _formatProvider; + private readonly Func _initAction; + private volatile IDisposable _sdkHandle; + + private readonly object _initSync = new object(); + + internal static readonly (string Name, string Version) NameAndVersion + = typeof(SentrySink).Assembly.GetNameAndVersion(); + + private static readonly string ProtocolPackageName = "nuget:" + NameAndVersion.Name; + + internal IHub Hub { get; set; } + + public string Dsn { get; set; } + + public SentrySink(IFormatProvider formatProvider) : this(formatProvider, SentrySdk.Init, HubAdapter.Instance) + { } + + internal SentrySink( + IFormatProvider formatProvider, + Func initAction, + IHub hub) + { + Debug.Assert(initAction != null); + Debug.Assert(hub != null); + + _formatProvider = formatProvider; + _initAction = initAction; + Hub = hub; + } + + public void Emit(LogEvent logEvent) + { + if (logEvent == null) + { + return; + } + + if (!Hub.IsEnabled && _sdkHandle == null) + { + if (Dsn == null) + { + return; + } + + lock (_initSync) + { + if (_sdkHandle == null) + { + _sdkHandle = _initAction(Dsn); + Debug.Assert(_sdkHandle != null); + } + } + } + + var exception = logEvent.Exception; + + var evt = new SentryEvent(exception) + { + Sdk = + { + Name = Constants.SdkName, + Version = NameAndVersion.Version + }, + LogEntry = new LogEntry + { + Formatted = logEvent.RenderMessage(_formatProvider), + Message = logEvent.MessageTemplate.Text + }, + Level = logEvent.Level.ToSentryLevel() + }; + + evt.Sdk.AddPackage(ProtocolPackageName, NameAndVersion.Version); + + evt.SetExtras(GetLoggingEventProperties(logEvent)); + + Hub.CaptureEvent(evt); + } + + private IEnumerable> GetLoggingEventProperties(LogEvent logEvent) + { + var properties = logEvent.Properties; + + foreach (var property in properties) + { + var value = property.Value; + if (value is ScalarValue scalarValue) + { + yield return new KeyValuePair(property.Key, scalarValue.Value); + } + else if (value != null) + { + yield return new KeyValuePair(property.Key, value); + } + } + } + + public void Dispose() + { + _sdkHandle?.Dispose(); + } + } +} diff --git a/src/Sentry.Serilog/SentrySinkExtensions.cs b/src/Sentry.Serilog/SentrySinkExtensions.cs new file mode 100644 index 0000000000..878d75a049 --- /dev/null +++ b/src/Sentry.Serilog/SentrySinkExtensions.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Serilog; +using Serilog.Configuration; +using Serilog.Events; + +namespace Sentry.Serilog +{ + public static class SentrySinkExtensions + { + public static LoggerConfiguration Sentry( + this LoggerSinkConfiguration loggerConfiguration, + string dsn = null, + LogEventLevel restrictedToMinimumLevel = LevelAlias.Minimum, + IFormatProvider formatProvider = null) + { + return loggerConfiguration.Sink(new SentrySink(formatProvider) { Dsn = dsn }, restrictedToMinimumLevel); + } + } +} diff --git a/src/Sentry/Properties/AssemblyInfo.cs b/src/Sentry/Properties/AssemblyInfo.cs index 612622152a..37e48b2aee 100644 --- a/src/Sentry/Properties/AssemblyInfo.cs +++ b/src/Sentry/Properties/AssemblyInfo.cs @@ -5,6 +5,7 @@ [assembly: InternalsVisibleTo("Sentry.Tests, PublicKey=002400000480000094000000060200000024000052534131000400000100010059964a931488bcdbd14657f1ee0df32df61b57b3d14d7290c262c2cc9ddaad6ec984044f761f778e1823049d2cb996a4f58c8ea5b46c37891414cb34b4036b1c178d7b582289d2eef3c0f1e9b692c229a306831ee3d371d9e883f0eb0f74aeac6c6ab8c85fd1ec04b267e15a31532c4b4e2191f5980459db4dce0081f1050fb8")] [assembly: InternalsVisibleTo("Sentry.Testing, PublicKey=002400000480000094000000060200000024000052534131000400000100010059964a931488bcdbd14657f1ee0df32df61b57b3d14d7290c262c2cc9ddaad6ec984044f761f778e1823049d2cb996a4f58c8ea5b46c37891414cb34b4036b1c178d7b582289d2eef3c0f1e9b692c229a306831ee3d371d9e883f0eb0f74aeac6c6ab8c85fd1ec04b267e15a31532c4b4e2191f5980459db4dce0081f1050fb8")] [assembly: InternalsVisibleTo("Sentry.Log4Net.Tests, PublicKey=002400000480000094000000060200000024000052534131000400000100010059964a931488bcdbd14657f1ee0df32df61b57b3d14d7290c262c2cc9ddaad6ec984044f761f778e1823049d2cb996a4f58c8ea5b46c37891414cb34b4036b1c178d7b582289d2eef3c0f1e9b692c229a306831ee3d371d9e883f0eb0f74aeac6c6ab8c85fd1ec04b267e15a31532c4b4e2191f5980459db4dce0081f1050fb8")] +[assembly: InternalsVisibleTo("Sentry.Serilog.Tests, PublicKey=002400000480000094000000060200000024000052534131000400000100010059964a931488bcdbd14657f1ee0df32df61b57b3d14d7290c262c2cc9ddaad6ec984044f761f778e1823049d2cb996a4f58c8ea5b46c37891414cb34b4036b1c178d7b582289d2eef3c0f1e9b692c229a306831ee3d371d9e883f0eb0f74aeac6c6ab8c85fd1ec04b267e15a31532c4b4e2191f5980459db4dce0081f1050fb8")] [assembly: InternalsVisibleTo("Sentry.AspNetCore.Tests, PublicKey=002400000480000094000000060200000024000052534131000400000100010059964a931488bcdbd14657f1ee0df32df61b57b3d14d7290c262c2cc9ddaad6ec984044f761f778e1823049d2cb996a4f58c8ea5b46c37891414cb34b4036b1c178d7b582289d2eef3c0f1e9b692c229a306831ee3d371d9e883f0eb0f74aeac6c6ab8c85fd1ec04b267e15a31532c4b4e2191f5980459db4dce0081f1050fb8")] [assembly: InternalsVisibleTo("Sentry.AspNetCore, PublicKey=002400000480000094000000060200000024000052534131000400000100010059964a931488bcdbd14657f1ee0df32df61b57b3d14d7290c262c2cc9ddaad6ec984044f761f778e1823049d2cb996a4f58c8ea5b46c37891414cb34b4036b1c178d7b582289d2eef3c0f1e9b692c229a306831ee3d371d9e883f0eb0f74aeac6c6ab8c85fd1ec04b267e15a31532c4b4e2191f5980459db4dce0081f1050fb8")] [assembly: InternalsVisibleTo("Sentry.Extensions.Logging, PublicKey=002400000480000094000000060200000024000052534131000400000100010059964a931488bcdbd14657f1ee0df32df61b57b3d14d7290c262c2cc9ddaad6ec984044f761f778e1823049d2cb996a4f58c8ea5b46c37891414cb34b4036b1c178d7b582289d2eef3c0f1e9b692c229a306831ee3d371d9e883f0eb0f74aeac6c6ab8c85fd1ec04b267e15a31532c4b4e2191f5980459db4dce0081f1050fb8")] diff --git a/test/Sentry.Serilog.Tests/Sentry.Serilog.Tests.csproj b/test/Sentry.Serilog.Tests/Sentry.Serilog.Tests.csproj new file mode 100644 index 0000000000..d9f3513008 --- /dev/null +++ b/test/Sentry.Serilog.Tests/Sentry.Serilog.Tests.csproj @@ -0,0 +1,11 @@ + + + + netcoreapp2.1;netcoreapp2.0;net462 + + + + + + + diff --git a/test/Sentry.Serilog.Tests/SentrySinkTests.cs b/test/Sentry.Serilog.Tests/SentrySinkTests.cs new file mode 100644 index 0000000000..25df6be009 --- /dev/null +++ b/test/Sentry.Serilog.Tests/SentrySinkTests.cs @@ -0,0 +1,225 @@ +using System; +using System.Linq; +using NSubstitute; +using Sentry.Protocol; +using Sentry.Reflection; +using Serilog.Events; +using Serilog.Parsing; +using Xunit; + +namespace Sentry.Serilog.Tests +{ + public class SentrySinkTests + { + private class Fixture + { + public bool InitInvoked { get; set; } + public string DsnReceivedOnInit { get; set; } + public IDisposable SdkDisposeHandle { get; set; } = Substitute.For(); + public Func InitAction { get; set; } + public IHub Hub { get; set; } = Substitute.For(); + public string Dsn { get; set; } = "dsn"; + + public Fixture() + { + InitAction = s => + { + DsnReceivedOnInit = s; + InitInvoked = true; + return SdkDisposeHandle; + }; + } + + public SentrySink GetSut() + { + var sut = new SentrySink(null, InitAction, Hub) + { + Dsn = Dsn + }; + + return sut; + } + } + + private readonly Fixture _fixture = new Fixture(); + + [Fact] + public void Sink_WithException_CreatesEventWithException() + { + var sut = _fixture.GetSut(); + + var expected = new Exception("expected"); + + var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Error, expected, MessageTemplate.Empty, + Enumerable.Empty()); + + sut.Emit(evt); + + _fixture.Hub.Received(1) + .CaptureEvent(Arg.Is(e => e.Exception == expected)); + } + + [Fact] + public void Sink_SerilogSdk_Name() + { + var sut = _fixture.GetSut(); + + var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Error, null, MessageTemplate.Empty, + Enumerable.Empty()); + + sut.Emit(evt); + + var expected = typeof(SentrySink).Assembly.GetNameAndVersion(); + _fixture.Hub.Received(1) + .CaptureEvent(Arg.Is(e => e.Sdk.Name == Constants.SdkName + && e.Sdk.Version == expected.Version)); + } + + [Fact] + public void Sink_SerilogSdk_Packages() + { + var sut = _fixture.GetSut(); + + var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Error, null, MessageTemplate.Empty, + Enumerable.Empty()); + + SentryEvent actual = null; + _fixture.Hub.When(h => h.CaptureEvent(Arg.Any())) + .Do(c => actual = c.Arg()); + + sut.Emit(evt); + + var expected = typeof(SentrySink).Assembly.GetNameAndVersion(); + + Assert.NotNull(actual); + var package = Assert.Single(actual.Sdk.Packages); + Assert.Equal("nuget:" + expected.Name, package.Name); + Assert.Equal(expected.Version, package.Version); + } + + [Fact] + public void Sink_LoggerLevel_Set() + { + const SentryLevel expectedLevel = SentryLevel.Error; + + var sut = _fixture.GetSut(); + + var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Error, null, MessageTemplate.Empty, + Enumerable.Empty()); + + sut.Emit(evt); + + _fixture.Hub.Received(1) + .CaptureEvent(Arg.Is(e => e.Level == expectedLevel)); + } + + [Fact] + public void Sink_RenderedMessage_Set() + { + const string expected = "message"; + + var sut = _fixture.GetSut(); + + var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Error, null, + new MessageTemplateParser().Parse(expected), Enumerable.Empty()); + + sut.Emit(evt); + + _fixture.Hub.Received(1) + .CaptureEvent(Arg.Is(e => e.LogEntry.Formatted == expected)); + } + + [Fact] + public void Sink_NoDsn_InitNotCalled() + { + _fixture.Dsn = null; + var sut = _fixture.GetSut(); + + var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Error, null, MessageTemplate.Empty, + Enumerable.Empty()); + sut.Emit(evt); + + Assert.False(_fixture.InitInvoked); + } + + [Fact] + public void Sink_WithDsn_InitCalled() + { + var sut = _fixture.GetSut(); + + var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Error, null, MessageTemplate.Empty, + Enumerable.Empty()); + sut.Emit(evt); + + Assert.True(_fixture.InitInvoked); + Assert.Same(_fixture.Dsn, _fixture.DsnReceivedOnInit); + } + + [Fact] + public void Sink_NoDsn_HubNotCalled() + { + _fixture.Dsn = null; + var sut = _fixture.GetSut(); + + var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Error, null, MessageTemplate.Empty, + Enumerable.Empty()); + sut.Emit(evt); + + Assert.False(_fixture.InitInvoked); + _fixture.Hub.DidNotReceiveWithAnyArgs().CaptureEvent(null); + } + + [Fact] + public void Sink_Properties_AsExtra() + { + const string expectedIp = "127.0.0.1"; + + var sut = _fixture.GetSut(); + + var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Error, null, MessageTemplate.Empty, + new[] { new LogEventProperty("IPAddress", new ScalarValue(expectedIp)) }); + + sut.Emit(evt); + + _fixture.Hub.Received(1) + .CaptureEvent(Arg.Is(e => e.Extra["IPAddress"].ToString() == expectedIp)); + } + + [Fact] + public void Close_DisposesSdk() + { + const string expectedDsn = "dsn"; + var sut = _fixture.GetSut(); + sut.Dsn = expectedDsn; + + var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Error, null, MessageTemplate.Empty, + Enumerable.Empty()); + sut.Emit(evt); + + _fixture.SdkDisposeHandle.DidNotReceive().Dispose(); + + sut.Dispose(); + + _fixture.SdkDisposeHandle.Received(1).Dispose(); + } + + [Fact] + public void Sink_WithFormat_EventCaptured() + { + const string expectedMessage = "Test {structured} log"; + const int param = 10; + + var sut = _fixture.GetSut(); + + var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Error, null, + new MessageTemplateParser().Parse(expectedMessage), + new[] { new LogEventProperty("structured", new ScalarValue(param)) }); + + sut.Emit(evt); + + _fixture.Hub.Received(1).CaptureEvent(Arg.Is(p => + p.LogEntry.Formatted == $"Test {param} log" + && p.LogEntry.Message == expectedMessage)); + } + } +}