diff --git a/src/Honeycomb.Serilog.Sink/Formatters/RawJsonFormatter.cs b/src/Honeycomb.Serilog.Sink/Formatters/RawJsonFormatter.cs new file mode 100644 index 0000000..99b0052 --- /dev/null +++ b/src/Honeycomb.Serilog.Sink/Formatters/RawJsonFormatter.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using Serilog.Events; +using Serilog.Formatting; +using Serilog.Formatting.Json; + +namespace Honeycomb.Serilog.Sink.Formatters +{ + internal class RawJsonFormatter : ITextFormatter + { + private static readonly JsonValueFormatter ValueFormatter = new JsonValueFormatter(); + + public void Format(LogEvent logEvent, TextWriter output) + { + FormatContent(logEvent, output); + } + + public static void FormatContent(LogEvent logEvent, TextWriter output) + { + if (logEvent == null) throw new ArgumentNullException(nameof(logEvent)); + if (output == null) throw new ArgumentNullException(nameof(output)); + + output.Write($"{{\"time\":\"{logEvent.Timestamp:O}\","); + output.Write($"\"data\":{{"); + output.Write($"\"level\":\"{logEvent.Level}\""); + output.Write(",\"messageTemplate\":"); + JsonValueFormatter.WriteQuotedJsonString(logEvent.MessageTemplate.Text, output); + if (logEvent.Exception != null) + { + output.Write(",\"exception\":"); + JsonValueFormatter.WriteQuotedJsonString(logEvent.Exception.ToString(), output); + } + + if (logEvent.Properties.Any()) + { + WriteProperties(logEvent.Properties, output); + } + + output.Write('}'); + output.Write('}'); + } + + private static void WriteProperties(IReadOnlyDictionary properties, TextWriter output) + { + var precedingDelimiter = ","; + foreach (var property in properties) + { + output.Write(precedingDelimiter); + + JsonValueFormatter.WriteQuotedJsonString(property.Key, output); + output.Write(':'); + ValueFormatter.Format(property.Value, output); + } + } + } +} diff --git a/src/Honeycomb.Serilog.Sink/Honeycomb.Serilog.Sink.csproj b/src/Honeycomb.Serilog.Sink/Honeycomb.Serilog.Sink.csproj index 91d718b..ccf24a6 100644 --- a/src/Honeycomb.Serilog.Sink/Honeycomb.Serilog.Sink.csproj +++ b/src/Honeycomb.Serilog.Sink/Honeycomb.Serilog.Sink.csproj @@ -1,7 +1,7 @@  - net461;netstandard2.0;netstandard2.1 + net461;netstandard2.0 MIT Honeycomb Serilog sink evilpilaf @@ -11,12 +11,22 @@ evilpilaf © $([System.DateTime]::Now.Year) + + NETCORE;NETSTANDARD;NETSTANDARD2_0 + + + + NET461;NETFULL + + all runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/src/Honeycomb.Serilog.Sink/HoneycombSerilogSink.cs b/src/Honeycomb.Serilog.Sink/HoneycombSerilogSink.cs index 4ae6d62..409f180 100644 --- a/src/Honeycomb.Serilog.Sink/HoneycombSerilogSink.cs +++ b/src/Honeycomb.Serilog.Sink/HoneycombSerilogSink.cs @@ -1,18 +1,20 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Net.Http; using System.Text; +using System.Threading.Tasks; + +using Honeycomb.Serilog.Sink.Formatters; -using Serilog.Core; using Serilog.Events; +using Serilog.Sinks.PeriodicBatching; namespace Honeycomb.Serilog.Sink { - internal class HoneycombSerilogSink : ILogEventSink + internal class HoneycombSerilogSink : PeriodicBatchingSink { - private static readonly Uri _honeycombApiUrl = new Uri("https://api.honeycomb.io/1/events/"); + private static readonly Uri _honeycombApiUrl = new Uri("https://api.honeycomb.io/"); private readonly string _teamId; private readonly string _apiKey; @@ -20,50 +22,52 @@ internal class HoneycombSerilogSink : ILogEventSink private readonly Lazy _clientBuilder = new Lazy(BuildHttpClient); protected virtual HttpClient Client => _clientBuilder.Value; - public HoneycombSerilogSink(string teamId, string apiKey) + /// The name of the team to submit the events to + /// The API key given in the Honeycomb ui + /// The maximum number of events to include in a single batch. + /// The time to wait between checking for event batches. + public HoneycombSerilogSink( + string teamId, + string apiKey, + int batchSizeLimit, + TimeSpan period) + : base(batchSizeLimit, period) { _teamId = string.IsNullOrWhiteSpace(teamId) ? throw new ArgumentNullException(nameof(teamId)) : teamId; _apiKey = string.IsNullOrWhiteSpace(apiKey) ? throw new ArgumentNullException(nameof(apiKey)) : apiKey; } - public void Emit(LogEvent logEvent) + protected override async Task EmitBatchAsync(IEnumerable events) { - using (var buffer = new StringWriter(new StringBuilder())) + using (TextWriter writer = new StringWriter()) { - var evnt = BuildLogEvent(logEvent); - var message = new HttpRequestMessage(HttpMethod.Post, $"/{_teamId}") - { - Content = new StringContent(evnt) - }; - message.Headers.Add("X-Honeycomb-Team", _apiKey); - Client.SendAsync(message).ConfigureAwait(false).GetAwaiter().GetResult(); + BuildLogEvent(events, writer); + await SendBatchedEvents(writer.ToString()); } } - private static string BuildLogEvent(LogEvent logEvent) + private async Task SendBatchedEvents(string events) { - var evnt = new StringBuilder("{"); - - var propertyList = new List(logEvent.Properties.Count() + 4) + var message = new HttpRequestMessage(HttpMethod.Post, $"/1/batch/{_teamId}") { - $"\"timestamp\": \"{logEvent.Timestamp:O}\"", - $"\"level\": \"{logEvent.Level}\"", - $"\"messageTemplate\": \"{logEvent.MessageTemplate}\"" + Content = new StringContent(events, Encoding.UTF8, "application/json") }; + message.Headers.Add("X-Honeycomb-Team", _apiKey); + var result = await Client.SendAsync(message).ConfigureAwait(false); + var response = await result.Content.ReadAsStringAsync().ConfigureAwait(false); + } - if (logEvent.Exception != null) - { - propertyList.Add($"\"exception\": \"{logEvent.Exception.ToString()}\""); - } - - foreach (var prop in logEvent.Properties) + private static void BuildLogEvent(IEnumerable logEvents, TextWriter payload) + { + payload.Write("["); + var eventSepparator = ""; + foreach (var evnt in logEvents) { - propertyList.Add($"\"{prop.Key}\": {prop.Value.ToString()}"); + payload.Write(eventSepparator); + eventSepparator = ","; + RawJsonFormatter.FormatContent(evnt, payload); } - - evnt.Append(string.Join(",", propertyList)); - evnt.Append("}"); - return evnt.ToString(); + payload.Write("]"); } private static HttpClient BuildHttpClient() @@ -72,7 +76,6 @@ private static HttpClient BuildHttpClient() { BaseAddress = _honeycombApiUrl }; - client.DefaultRequestHeaders.Add("Content-Type", "application/json"); return client; } } diff --git a/src/Honeycomb.Serilog.Sink/HoneycombSinkExtensions.cs b/src/Honeycomb.Serilog.Sink/HoneycombSinkExtensions.cs index b223fd7..e6da33f 100644 --- a/src/Honeycomb.Serilog.Sink/HoneycombSinkExtensions.cs +++ b/src/Honeycomb.Serilog.Sink/HoneycombSinkExtensions.cs @@ -1,3 +1,5 @@ +using System; + using Serilog; using Serilog.Configuration; @@ -5,11 +7,17 @@ namespace Honeycomb.Serilog.Sink { public static class HoneycombSinkExtensions { + /// The name of the team to submit the events to + /// The API key given in the Honeycomb ui + /// The maximum number of events to include in a single batch. + /// The time to wait between checking for event batches. public static LoggerConfiguration HoneycombSink(this LoggerSinkConfiguration loggerConfiguration, string teamId, - string apiKey) + string apiKey, + int batchSizeLimit, + TimeSpan period) { - return loggerConfiguration.Sink(new HoneycombSerilogSink(teamId, apiKey)); + return loggerConfiguration.Sink(new HoneycombSerilogSink(teamId, apiKey, batchSizeLimit, period)); } } } diff --git a/test/Honeycomb.Serilog.Sink.Tests/Helpers/HttpMessageHandlerStub.cs b/test/Honeycomb.Serilog.Sink.Tests/Helpers/HttpMessageHandlerStub.cs index 7a5fc6b..cbd9d5c 100644 --- a/test/Honeycomb.Serilog.Sink.Tests/Helpers/HttpMessageHandlerStub.cs +++ b/test/Honeycomb.Serilog.Sink.Tests/Helpers/HttpMessageHandlerStub.cs @@ -15,7 +15,10 @@ protected override async Task SendAsync(HttpRequestMessage { RequestMessage = request; RequestContent = await request.Content.ReadAsStringAsync(); - return new HttpResponseMessage(_statusCodeToReturn); + return new HttpResponseMessage(_statusCodeToReturn) + { + Content = new StringContent("") + }; } public void ReturnsStatusCode(HttpStatusCode statusCode) diff --git a/test/Honeycomb.Serilog.Sink.Tests/Helpers/Some.cs b/test/Honeycomb.Serilog.Sink.Tests/Helpers/Some.cs new file mode 100644 index 0000000..eee34c3 --- /dev/null +++ b/test/Honeycomb.Serilog.Sink.Tests/Helpers/Some.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; + +using Serilog; +using Serilog.Events; + +using Xunit.Sdk; + +namespace Honeycomb.Serilog.Sink.Tests.Helpers +{ + static class Some + { + public static LogEvent LogEvent(string messageTemplate, params object[] propertyValues) + { + return LogEvent(null, messageTemplate, propertyValues); + } + + public static LogEvent LogEvent(Exception exception, string messageTemplate, params object[] propertyValues) + { + return LogEvent(LogEventLevel.Information, exception, messageTemplate, propertyValues); + } + + public static LogEvent LogEvent(LogEventLevel level, Exception exception, string messageTemplate, params object[] propertyValues) + { + var log = new LoggerConfiguration().CreateLogger(); + MessageTemplate template; + IEnumerable properties; +#pragma warning disable Serilog004 // Constant MessageTemplate verifier + if (!log.BindMessageTemplate(messageTemplate, propertyValues, out template, out properties)) +#pragma warning restore Serilog004 // Constant MessageTemplate verifier + { + throw new XunitException("Template could not be bound."); + } + return new LogEvent(DateTimeOffset.Now, level, exception, template, properties); + } + + public static LogEvent LogEvent(LogEventLevel level, string messageTemplate, params object[] propertyValues) + { + var log = new LoggerConfiguration().CreateLogger(); + +#pragma warning disable Serilog004 // Constant MessageTemplate verifier + if (!log.BindMessageTemplate(messageTemplate, propertyValues, out var template, out var properties)) +#pragma warning restore Serilog004 // Constant MessageTemplate verifier + { + throw new XunitException("Template could not be bound."); + } + return new LogEvent(DateTimeOffset.Now, level, null, template, properties); + } + + public static LogEvent DebugEvent() + { + return LogEvent(LogEventLevel.Debug, null, "Debug event"); + } + + public static LogEvent InformationEvent() + { + return LogEvent(LogEventLevel.Information, null, "Information event"); + } + + public static LogEvent ErrorEvent() + { + return LogEvent(LogEventLevel.Error, null, "Error event"); + } + + public static string String() + { + return Guid.NewGuid().ToString("n"); + } + } +} diff --git a/test/Honeycomb.Serilog.Sink.Tests/HoneycombSerilogSinkStub.cs b/test/Honeycomb.Serilog.Sink.Tests/HoneycombSerilogSinkStub.cs index 331a62b..f8e788e 100644 --- a/test/Honeycomb.Serilog.Sink.Tests/HoneycombSerilogSinkStub.cs +++ b/test/Honeycomb.Serilog.Sink.Tests/HoneycombSerilogSinkStub.cs @@ -1,4 +1,9 @@ -using System.Net.Http; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; + +using Serilog.Events; namespace Honeycomb.Serilog.Sink.Tests { @@ -6,12 +11,17 @@ internal class HoneycombSerilogSinkStub : HoneycombSerilogSink { private readonly HttpClient _client; - public HoneycombSerilogSinkStub(HttpClient client, string teamId, string apiKey) - : base(teamId, apiKey) + public HoneycombSerilogSinkStub(HttpClient client, string teamId, string apiKey, int batchSizeLimit, TimeSpan period) + : base(teamId, apiKey, batchSizeLimit, period) { _client = client; } protected override HttpClient Client => _client; + + public Task EmitTestable(params LogEvent[] events) + { + return EmitBatchAsync(events); + } } } diff --git a/test/Honeycomb.Serilog.Sink.Tests/HoneycombSerilogSinkTests.cs b/test/Honeycomb.Serilog.Sink.Tests/HoneycombSerilogSinkTests.cs index 5a5d545..8cd03f1 100644 --- a/test/Honeycomb.Serilog.Sink.Tests/HoneycombSerilogSinkTests.cs +++ b/test/Honeycomb.Serilog.Sink.Tests/HoneycombSerilogSinkTests.cs @@ -2,11 +2,13 @@ using System.Linq; using System.Net.Http; using System.Text.Json; +using System.Threading.Tasks; using FluentAssertions; using FluentAssertions.Execution; using Honeycomb.Serilog.Sink.Tests.Builders; +using Honeycomb.Serilog.Sink.Tests.Helpers; using Serilog.Events; using Serilog.Parsing; @@ -42,7 +44,7 @@ public void Create_WhenInvalidApiKeyIsProvided_ThrowsArgumentException(string ap } [Fact] - public void Emit_AlwaysSendsApiKey() + public async Task Emit_AlwaysSendsApiKeyAsync() { const string teamId = nameof(teamId); const string apiKey = nameof(apiKey); @@ -51,14 +53,14 @@ public void Emit_AlwaysSendsApiKey() var sut = CreateSut(teamId, apiKey, clientStub); - sut.Emit(new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, new MessageTemplate("", Enumerable.Empty()), Enumerable.Empty())); + await sut.EmitTestable(new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, new MessageTemplate("", Enumerable.Empty()), Enumerable.Empty())); clientStub.RequestSubmitted.Headers.Should().ContainSingle(h => h.Key == "X-Honeycomb-Team"); clientStub.RequestSubmitted.Headers.GetValues("X-Honeycomb-Team").Should().ContainSingle().Which.Should().Be(apiKey); } [Fact] - public void Emit_CallsEndpointUsingTeamId() + public async Task Emit_CallsEndpointUsingTeamId() { const string teamId = nameof(teamId); const string apiKey = nameof(apiKey); @@ -67,13 +69,13 @@ public void Emit_CallsEndpointUsingTeamId() var sut = CreateSut(teamId, apiKey, clientStub); - sut.Emit(new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, new MessageTemplate("", Enumerable.Empty()), Enumerable.Empty())); + await sut.EmitTestable(new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, new MessageTemplate("", Enumerable.Empty()), Enumerable.Empty())); clientStub.RequestSubmitted.RequestUri.ToString().Should().EndWith(teamId); } [Fact] - public void Emit_GivenNoExceptionIsLogged_SerializesLogMessageAsJson_HasNoExceptionInMessage() + public async Task Emit_GivenNoExceptionIsLogged_SerializesLogMessageAsJson_HasNoExceptionInMessageAsync() { const string teamId = nameof(teamId); const string apiKey = nameof(apiKey); @@ -83,29 +85,34 @@ public void Emit_GivenNoExceptionIsLogged_SerializesLogMessageAsJson_HasNoExcept var sut = CreateSut(teamId, apiKey, clientStub); var level = LogEventLevel.Fatal; - var eventTime = DateTimeOffset.Now; - var messageTemplateParser = new MessageTemplateParser(); var messageTempalteString = "Testing message {message}"; - var messageTemplate = messageTemplateParser.Parse(messageTempalteString); - sut.Emit(new LogEvent(eventTime, level, null, messageTemplate, Enumerable.Empty())); - //sut.Emit(new LogEvent(eventTime, level, null, messageTemplate, new[] { new LogEventProperty("message", new ScalarValue("hello")), new LogEventProperty("message2", new ScalarValue("hello2")) })); + var eventToSend = Some.LogEvent(level, messageTempalteString); + + await sut.EmitTestable(eventToSend); var requestContent = clientStub.RequestContent; using (var document = JsonDocument.Parse(requestContent)) using (new AssertionScope()) { - document.RootElement.GetProperty("level").GetString().Should().Be(level.ToString()); - document.RootElement.GetProperty("timestamp").GetDateTimeOffset().Should().Be(eventTime); - document.RootElement.GetProperty("messageTemplate").GetString().Should().Be(messageTempalteString); - document.RootElement.TryGetProperty("exception", out var ex); + document.RootElement.ValueKind.Should().Be(JsonValueKind.Array); + document.RootElement.GetArrayLength().Should().Be(1); + JsonElement sentEvent = document.RootElement.EnumerateArray().Single(); + + sentEvent.GetProperty("time").GetDateTimeOffset().Should().Be(eventToSend.Timestamp); + sentEvent.GetProperty("data").ValueKind.Should().Be(JsonValueKind.Object); + + JsonElement data = sentEvent.GetProperty("data"); + data.GetProperty("level").GetString().Should().Be(level.ToString()); + data.GetProperty("messageTemplate").GetString().Should().Be(messageTempalteString); + data.TryGetProperty("exception", out var ex); ex.ValueKind.Should().Be(JsonValueKind.Undefined); } } [Fact] - public void Emit_GivenAnExceptionToLog_SerializesLogMessageAsJson_IncludesExceptionInMessage() + public async Task Emit_GivenAnExceptionToLog_SerializesLogMessageAsJson_IncludesExceptionInMessageAsync() { const string teamId = nameof(teamId); const string apiKey = nameof(apiKey); @@ -115,28 +122,34 @@ public void Emit_GivenAnExceptionToLog_SerializesLogMessageAsJson_IncludesExcept var sut = CreateSut(teamId, apiKey, clientStub); var level = LogEventLevel.Fatal; - var eventTime = DateTimeOffset.Now; - var messageTemplateParser = new MessageTemplateParser(); var messageTempalteString = "Testing message {message}"; - var messageTemplate = messageTemplateParser.Parse(messageTempalteString); var ex = new Exception("TestException"); - sut.Emit(new LogEvent(eventTime, level, ex, messageTemplate, Enumerable.Empty())); + var eventToSend = Some.LogEvent(level, ex, messageTempalteString); + + await sut.EmitTestable(eventToSend); var requestContent = clientStub.RequestContent; using (var document = JsonDocument.Parse(requestContent)) using (new AssertionScope()) { - document.RootElement.GetProperty("level").GetString().Should().Be(level.ToString()); - document.RootElement.GetProperty("timestamp").GetDateTimeOffset().Should().Be(eventTime); - document.RootElement.GetProperty("messageTemplate").GetString().Should().Be(messageTempalteString); - document.RootElement.GetProperty("exception").GetString().Should().Be(ex.ToString()); + document.RootElement.ValueKind.Should().Be(JsonValueKind.Array); + document.RootElement.GetArrayLength().Should().Be(1); + JsonElement sentEvent = document.RootElement.EnumerateArray().Single(); + + sentEvent.GetProperty("time").GetDateTimeOffset().Should().Be(eventToSend.Timestamp); + sentEvent.GetProperty("data").ValueKind.Should().Be(JsonValueKind.Object); + JsonElement data = sentEvent.GetProperty("data"); + + data.GetProperty("level").GetString().Should().Be(level.ToString()); + data.GetProperty("messageTemplate").GetString().Should().Be(messageTempalteString); + data.GetProperty("exception").GetString().Should().Be(ex.ToString()); } } [Fact] - public void Emit_GivenAMessageWithProperties_SendsThemAll() + public async Task Emit_GivenAMessageWithProperties_SendsThemAllAsync() { const string teamId = nameof(teamId); const string apiKey = nameof(apiKey); @@ -146,30 +159,35 @@ public void Emit_GivenAMessageWithProperties_SendsThemAll() var sut = CreateSut(teamId, apiKey, clientStub); var level = LogEventLevel.Fatal; - var eventTime = DateTimeOffset.Now; - var messageTemplateParser = new MessageTemplateParser(); - var messageTempalteString = "Testing message {message}"; - var messageTemplate = messageTemplateParser.Parse(messageTempalteString); - var ex = new Exception("TestException"); + const string property = nameof(property); + + var messageTempalteString = $"Testing message property {{{nameof(property)}}}"; - const string propertyName = nameof(propertyName); - const string propertyValue = nameof(propertyValue); - var properties = new LogEventProperty(propertyName, new ScalarValue(propertyValue)); + var eventToSend = Some.LogEvent(level, messageTempalteString, property); - sut.Emit(new LogEvent(eventTime, level, ex, messageTemplate, new[] { properties })); + await sut.EmitTestable(eventToSend); var requestContent = clientStub.RequestContent; using (var document = JsonDocument.Parse(requestContent)) using (new AssertionScope()) { - document.RootElement.GetProperty(propertyName).GetString().Should().Be(propertyValue); + document.RootElement.ValueKind.Should().Be(JsonValueKind.Array); + document.RootElement.GetArrayLength().Should().Be(1); + JsonElement sentEvent = document.RootElement.EnumerateArray().Single(); + + sentEvent.GetProperty("time").GetDateTimeOffset().Should().Be(eventToSend.Timestamp); + sentEvent.GetProperty("data").ValueKind.Should().Be(JsonValueKind.Object); + + JsonElement data = sentEvent.GetProperty("data"); + + data.GetProperty(nameof(property)).GetString().Should().Be(property); } } - private HoneycombSerilogSink CreateSut(string teamId, string apiKey, HttpClient client = null) + private HoneycombSerilogSinkStub CreateSut(string teamId, string apiKey, HttpClient client = null) { - return new HoneycombSerilogSinkStub(client, teamId, apiKey); + return new HoneycombSerilogSinkStub(client, teamId, apiKey, 1, TimeSpan.FromMilliseconds(1)); } } }