diff --git a/global.json b/global.json index 77c776f..be44e52 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "7.0.100", + "version": "8.0.201", "rollForward": "latestFeature" } } diff --git a/sample/BlazorWasm/BlazorWasm.csproj b/sample/BlazorWasm/BlazorWasm.csproj index a5e21f2..4054529 100644 --- a/sample/BlazorWasm/BlazorWasm.csproj +++ b/sample/BlazorWasm/BlazorWasm.csproj @@ -1,16 +1,16 @@ - net7.0 + net8.0 enable enable - - - - + + + + diff --git a/sample/Sample/Sample.csproj b/sample/Sample/Sample.csproj index 2ac37e9..6257240 100644 --- a/sample/Sample/Sample.csproj +++ b/sample/Sample/Sample.csproj @@ -1,7 +1,7 @@  - net7.0 + net8.0 Exe latest enable @@ -9,7 +9,7 @@ - + diff --git a/src/Serilog.Sinks.Seq/SeqLoggerConfigurationExtensions.cs b/src/Serilog.Sinks.Seq/SeqLoggerConfigurationExtensions.cs index e020d33..39b746c 100644 --- a/src/Serilog.Sinks.Seq/SeqLoggerConfigurationExtensions.cs +++ b/src/Serilog.Sinks.Seq/SeqLoggerConfigurationExtensions.cs @@ -19,7 +19,6 @@ using Serilog.Sinks.Seq; using System.Net.Http; using Serilog.Formatting; -using Serilog.Formatting.Compact; using Serilog.Sinks.PeriodicBatching; using Serilog.Sinks.Seq.Batched; using Serilog.Sinks.Seq.Audit; @@ -36,8 +35,8 @@ public static class SeqLoggerConfigurationExtensions const int DefaultBatchPostingLimit = 1000; static readonly TimeSpan DefaultPeriod = TimeSpan.FromSeconds(2); const int DefaultQueueSizeLimit = 100000; - static ITextFormatter CreateDefaultFormatter() => new CompactJsonFormatter(new("$type")); - + static ITextFormatter CreateDefaultFormatter() => new SeqCompactJsonFormatter(); + /// /// Write log events to a Seq server. /// diff --git a/src/Serilog.Sinks.Seq/Serilog.Sinks.Seq.csproj b/src/Serilog.Sinks.Seq/Serilog.Sinks.Seq.csproj index 29039c3..4dff7a6 100644 --- a/src/Serilog.Sinks.Seq/Serilog.Sinks.Seq.csproj +++ b/src/Serilog.Sinks.Seq/Serilog.Sinks.Seq.csproj @@ -2,7 +2,7 @@ A Serilog sink that writes events to Seq using newline-delimited JSON and HTTP/HTTPS. - 6.0.0 + 7.0.0 Serilog Contributors;Serilog.Sinks.Seq Contributors;Datalust Pty Ltd Copyright © Serilog Contributors, Serilog.Sinks.Seq Contributors, Datalust Pty Ltd. netstandard2.0;net6.0 @@ -30,8 +30,7 @@ - - + diff --git a/src/Serilog.Sinks.Seq/Sinks/Seq/Http/SeqIngestionApiClient.cs b/src/Serilog.Sinks.Seq/Sinks/Seq/Http/SeqIngestionApiClient.cs index 76906fb..c95fa2f 100644 --- a/src/Serilog.Sinks.Seq/Sinks/Seq/Http/SeqIngestionApiClient.cs +++ b/src/Serilog.Sinks.Seq/Sinks/Seq/Http/SeqIngestionApiClient.cs @@ -29,7 +29,7 @@ sealed class SeqIngestionApiClient : SeqIngestionApi { const string BulkUploadResource = "api/events/raw"; const string ApiKeyHeaderName = "X-Seq-ApiKey"; - + readonly string? _apiKey; readonly HttpClient _httpClient; @@ -50,6 +50,7 @@ public SeqIngestionApiClient(string serverUrl, string? apiKey, HttpMessageHandle _httpClient = new HttpClient(); } #endif + #if SOCKETS_HTTP_HANDLER_ALWAYS_DEFAULT else { @@ -59,14 +60,19 @@ public SeqIngestionApiClient(string serverUrl, string? apiKey, HttpMessageHandle // require that the Seq API be accessed at a different IP address. Setting a timeout here puts // an upper bound on the duration of DNS-related outages, while hopefully incurring only infrequent // connection reestablishment costs. - PooledConnectionLifetime = TimeSpan.FromMinutes(5) + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + + // Don't trace the sink's own requests. On platforms that use alternative message handler implementations + // by default, the caller needs to do this manually and pass a handler through. Where `SocketsHttpHandler` + // is the default, we can safely set this without inadvertently causing behavior changes. + ActivityHeadersPropagator = null }); } #else - else - { - _httpClient = new HttpClient(); - } + else + { + _httpClient = new HttpClient(); + } #endif _httpClient.BaseAddress = new Uri(NormalizeServerBaseAddress(serverUrl)); diff --git a/src/Serilog.Sinks.Seq/Sinks/Seq/SeqCompactJsonFormatter.cs b/src/Serilog.Sinks.Seq/Sinks/Seq/SeqCompactJsonFormatter.cs new file mode 100644 index 0000000..591ae89 --- /dev/null +++ b/src/Serilog.Sinks.Seq/Sinks/Seq/SeqCompactJsonFormatter.cs @@ -0,0 +1,164 @@ +// Copyright 2016 Serilog Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using Serilog.Events; +using Serilog.Formatting; +using Serilog.Formatting.Json; +using Serilog.Parsing; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable PossibleMultipleEnumeration + +namespace Serilog.Sinks.Seq; + +/// +/// An that writes events in a compact JSON format. +/// +/// Modified from Serilog.Formatting.Compact.CompactJsonFormatter to add +/// implicit SerilogTracing span support. +public class SeqCompactJsonFormatter: ITextFormatter +{ + readonly JsonValueFormatter _valueFormatter = new("$type"); + + /// + /// Format the log event into the output. Subsequent events will be newline-delimited. + /// + /// The event to format. + /// The output. + public void Format(LogEvent logEvent, TextWriter output) + { + FormatEvent(logEvent, output, _valueFormatter); + output.WriteLine(); + } + + /// + /// Format the log event into the output. + /// + /// The event to format. + /// The output. + /// A value formatter for s on the event. + public static void FormatEvent(LogEvent logEvent, TextWriter output, JsonValueFormatter valueFormatter) + { + if (logEvent == null) throw new ArgumentNullException(nameof(logEvent)); + if (output == null) throw new ArgumentNullException(nameof(output)); + if (valueFormatter == null) throw new ArgumentNullException(nameof(valueFormatter)); + + output.Write("{\"@t\":\""); + output.Write(logEvent.Timestamp.UtcDateTime.ToString("O")); + output.Write("\",\"@mt\":"); + JsonValueFormatter.WriteQuotedJsonString(logEvent.MessageTemplate.Text, output); + + var tokensWithFormat = logEvent.MessageTemplate.Tokens + .OfType() + .Where(pt => pt.Format != null); + + // Better not to allocate an array in the 99.9% of cases where this is false + // ReSharper disable once PossibleMultipleEnumeration + if (tokensWithFormat.Any()) + { + output.Write(",\"@r\":["); + var delim = ""; + foreach (var r in tokensWithFormat) + { + output.Write(delim); + delim = ","; + var space = new StringWriter(); + r.Render(logEvent.Properties, space, CultureInfo.InvariantCulture); + JsonValueFormatter.WriteQuotedJsonString(space.ToString(), output); + } + output.Write(']'); + } + + if (logEvent.Level != LogEventLevel.Information) + { + output.Write(",\"@l\":\""); + output.Write(logEvent.Level); + output.Write('\"'); + } + + if (logEvent.Exception != null) + { + output.Write(",\"@x\":"); + JsonValueFormatter.WriteQuotedJsonString(logEvent.Exception.ToString(), output); + } + + if (logEvent.TraceId != null) + { + output.Write(",\"@tr\":\""); + output.Write(logEvent.TraceId.Value.ToHexString()); + output.Write('\"'); + } + + if (logEvent.SpanId != null) + { + output.Write(",\"@sp\":\""); + output.Write(logEvent.SpanId.Value.ToHexString()); + output.Write('\"'); + } + + var skipSpanProperties = false; + if (logEvent is {TraceId: not null, SpanId: not null} && + logEvent.Properties.TryGetValue("SpanStartTimestamp", out var st) && + st is ScalarValue { Value: DateTime spanStartTimestamp }) + { + skipSpanProperties = true; + + output.Write(",\"@st\":\""); + output.Write(spanStartTimestamp.ToString("o")); + output.Write('\"'); + + if (logEvent.Properties.TryGetValue("ParentSpanId", out var ps) && + ps is ScalarValue { Value: ActivitySpanId parentSpanId }) + { + output.Write(",\"@ps\":\""); + output.Write(parentSpanId.ToHexString()); + output.Write('\"'); + } + + if (logEvent.Properties.TryGetValue("SpanKind", out var sk) && + sk is ScalarValue { Value: ActivityKind spanKind } && + spanKind != ActivityKind.Internal) + { + output.Write(",\"@sk\":\""); + output.Write(spanKind); + output.Write('\"'); + } + } + + foreach (var property in logEvent.Properties) + { + var name = property.Key; + + if (skipSpanProperties && name is "SpanStartTimestamp" or "ParentSpanId" or "SpanKind") + continue; + + if (name.Length > 0 && name[0] == '@') + { + // Escape first '@' by doubling + name = '@' + name; + } + + output.Write(','); + JsonValueFormatter.WriteQuotedJsonString(name, output); + output.Write(':'); + valueFormatter.Format(property.Value, output); + } + + output.Write('}'); + } +} \ No newline at end of file diff --git a/test/Serilog.Sinks.Seq.Tests/Audit/SeqAuditSinkTests.cs b/test/Serilog.Sinks.Seq.Tests/Audit/SeqAuditSinkTests.cs index 48a4697..02cd774 100644 --- a/test/Serilog.Sinks.Seq.Tests/Audit/SeqAuditSinkTests.cs +++ b/test/Serilog.Sinks.Seq.Tests/Audit/SeqAuditSinkTests.cs @@ -3,7 +3,6 @@ using System.Threading.Tasks; using Serilog.Debugging; using Serilog.Events; -using Serilog.Formatting.Compact; using Serilog.Sinks.Seq.Audit; using Serilog.Sinks.Seq.Tests.Support; using Xunit; @@ -40,7 +39,7 @@ public void RemoteCommunicationErrorsPropagateToCallerWhenAuditing() public void AuditSinkDisposesIngestionApi() { var api = new TestIngestionApi(); - var sink = new SeqAuditSink(api, new CompactJsonFormatter()); + var sink = new SeqAuditSink(api, new SeqCompactJsonFormatter()); Assert.False(api.IsDisposed); sink.Dispose(); @@ -54,7 +53,7 @@ public async Task AuditSinkEmitsIndividualEvents() LogEvent evt1 = Some.InformationEvent("first"), evt2 = Some.InformationEvent("second"); var api = new TestIngestionApi(); - var sink = new SeqAuditSink(api, new CompactJsonFormatter()); + var sink = new SeqAuditSink(api, new SeqCompactJsonFormatter()); sink.Emit(evt1); sink.Emit(evt2); @@ -71,7 +70,7 @@ public void AuditSinkPropagatesExceptions() { var expected = new Exception("Test"); var api = new TestIngestionApi(_ => throw expected); - var sink = new SeqAuditSink(api, new CompactJsonFormatter()); + var sink = new SeqAuditSink(api, new SeqCompactJsonFormatter()); var thrown = Assert.Throws(() => sink.Emit(Some.InformationEvent())); diff --git a/test/Serilog.Sinks.Seq.Tests/Batched/BatchedSeqSinkTests.cs b/test/Serilog.Sinks.Seq.Tests/Batched/BatchedSeqSinkTests.cs index bbff8b2..679e6df 100644 --- a/test/Serilog.Sinks.Seq.Tests/Batched/BatchedSeqSinkTests.cs +++ b/test/Serilog.Sinks.Seq.Tests/Batched/BatchedSeqSinkTests.cs @@ -2,7 +2,6 @@ using System.Threading.Tasks; using Serilog.Core; using Serilog.Events; -using Serilog.Formatting.Compact; using Serilog.Sinks.Seq.Batched; using Serilog.Sinks.Seq.Http; using Serilog.Sinks.Seq.Tests.Support; @@ -16,7 +15,7 @@ public class BatchedSeqSinkTests public void BatchedSinkDisposesIngestionApi() { var api = new TestIngestionApi(); - var sink = new BatchedSeqSink(api, new CompactJsonFormatter(), null, new ControlledLevelSwitch()); + var sink = new BatchedSeqSink(api, new SeqCompactJsonFormatter(), null, new ControlledLevelSwitch()); Assert.False(api.IsDisposed); sink.Dispose(); @@ -28,7 +27,7 @@ public void BatchedSinkDisposesIngestionApi() public async Task EventsAreFormattedIntoPayloads() { var api = new TestIngestionApi(); - var sink = new BatchedSeqSink(api, new CompactJsonFormatter(), null, new ControlledLevelSwitch()); + var sink = new BatchedSeqSink(api, new SeqCompactJsonFormatter(), null, new ControlledLevelSwitch()); await sink.EmitBatchAsync(new[] { @@ -48,7 +47,7 @@ public async Task MinimumLevelIsControlled() const LogEventLevel originalLevel = LogEventLevel.Debug, newLevel = LogEventLevel.Error; var levelSwitch = new LoggingLevelSwitch(originalLevel); var api = new TestIngestionApi(_ => Task.FromResult(new IngestionResult(true, HttpStatusCode.Accepted, newLevel))); - var sink = new BatchedSeqSink(api, new CompactJsonFormatter(), null, new ControlledLevelSwitch(levelSwitch)); + var sink = new BatchedSeqSink(api, new SeqCompactJsonFormatter(), null, new ControlledLevelSwitch(levelSwitch)); await sink.EmitBatchAsync(new[] { Some.InformationEvent() }); diff --git a/test/Serilog.Sinks.Seq.Tests/ConstrainedBufferedFormatterTests.cs b/test/Serilog.Sinks.Seq.Tests/ConstrainedBufferedFormatterTests.cs index d01db2f..ff8d839 100644 --- a/test/Serilog.Sinks.Seq.Tests/ConstrainedBufferedFormatterTests.cs +++ b/test/Serilog.Sinks.Seq.Tests/ConstrainedBufferedFormatterTests.cs @@ -1,5 +1,4 @@ using System.IO; -using Serilog.Formatting.Compact; using Serilog.Sinks.Seq.Tests.Support; using Xunit; @@ -11,7 +10,7 @@ public class ConstrainedBufferedFormatterTests public void EventsAreFormattedIntoCompactJsonPayloads() { var evt = Some.LogEvent("Hello, {Name}!", "Alice"); - var formatter = new ConstrainedBufferedFormatter(null, new CompactJsonFormatter()); + var formatter = new ConstrainedBufferedFormatter(null, new SeqCompactJsonFormatter()); var json = new StringWriter(); formatter.Format(evt, json); Assert.Contains("Name\":\"Alice", json.ToString()); @@ -21,7 +20,7 @@ public void EventsAreFormattedIntoCompactJsonPayloads() public void PlaceholdersAreLoggedWhenCompactJsonRenderingFails() { var evt = Some.LogEvent(new NastyException(), "Hello, {Name}!", "Alice"); - var formatter = new ConstrainedBufferedFormatter(null, new CompactJsonFormatter()); + var formatter = new ConstrainedBufferedFormatter(null, new SeqCompactJsonFormatter()); var json = new StringWriter(); formatter.Format(evt, json); var jsonString = json.ToString(); @@ -33,7 +32,7 @@ public void PlaceholdersAreLoggedWhenCompactJsonRenderingFails() public void PlaceholdersAreLoggedWhenTheEventSizeLimitIsExceeded() { var evt = Some.LogEvent("Hello, {Name}!", new string('a', 10000)); - var formatter = new ConstrainedBufferedFormatter(2000, new CompactJsonFormatter()); + var formatter = new ConstrainedBufferedFormatter(2000, new SeqCompactJsonFormatter()); var json = new StringWriter(); formatter.Format(evt, json); var jsonString = json.ToString(); diff --git a/test/Serilog.Sinks.Seq.Tests/SeqCompactJsonFormatterTests.cs b/test/Serilog.Sinks.Seq.Tests/SeqCompactJsonFormatterTests.cs new file mode 100644 index 0000000..885ca68 --- /dev/null +++ b/test/Serilog.Sinks.Seq.Tests/SeqCompactJsonFormatterTests.cs @@ -0,0 +1,170 @@ +// This file originally CompactJsonFormatterTests from https://github.com/serilog/serilog-formatting-compact, +// Copyright Serilog Contributors and distributed under the Apache 2.0 license. + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Serilog.Events; +using Serilog.Parsing; +using Xunit; +// ReSharper disable AccessToDisposedClosure + +namespace Serilog.Sinks.Seq.Tests; + +public class SeqCompactJsonFormatterTests +{ + JObject AssertValidJson(Action act) + { + var sw = new StringWriter(); + var logger = new LoggerConfiguration() + .Destructure.AsScalar() + .Destructure.AsScalar() + .WriteTo.TextWriter(new SeqCompactJsonFormatter(), sw) + .CreateLogger(); + act(logger); + logger.Dispose(); + var json = sw.ToString(); + + var settings = new JsonSerializerSettings + { + DateParseHandling = DateParseHandling.None, + CheckAdditionalContent = true, + }; + + return JsonConvert.DeserializeObject(json, settings)!; + } + + [Fact] + public void AnEmptyEventIsValidJson() + { + AssertValidJson(log => log.Information("No properties")); + } + + [Fact] + public void AMinimalEventIsValidJson() + { + AssertValidJson(log => log.Information("One {Property}", 42)); + } + + [Fact] + public void MultiplePropertiesAreDelimited() + { + AssertValidJson(log => log.Information("Property {First} and {Second}", "One", "Two")); + } + + [Fact] + public void ExceptionsAreFormattedToValidJson() + { + AssertValidJson(log => log.Information(new DivideByZeroException(), "With exception")); + } + + [Fact] + public void ExceptionAndPropertiesAreValidJson() + { + AssertValidJson(log => log.Information(new DivideByZeroException(), "With exception and {Property}", 42)); + } + + [Fact] + public void RenderingsAreValidJson() + { + AssertValidJson(log => log.Information("One {Rendering:x8}", 42)); + } + + [Fact] + public void MultipleRenderingsAreDelimited() + { + AssertValidJson(log => log.Information("Rendering {First:x8} and {Second:x8}", 1, 2)); + } + + [Fact] + public void AtPrefixedPropertyNamesAreEscaped() + { + // Not possible in message templates, but accepted this way + var jObject = AssertValidJson(log => log.ForContext("@Mistake", 42) + .Information("Hello")); + + Assert.True(jObject.TryGetValue("@@Mistake", out var val)); + Assert.Equal(42, val.ToObject()); + } + + [Fact] + public void TimestampIsUtc() + { + // Not possible in message templates, but accepted this way + var jObject = AssertValidJson(log => log.Information("Hello")); + + Assert.True(jObject.TryGetValue("@t", out var val)); + Assert.EndsWith("Z", val.ToObject()); + } + + [Fact] + public void TraceAndSpanIdsGenerateValidJson() + { + var traceId = ActivityTraceId.CreateRandom(); + var spanId = ActivitySpanId.CreateRandom(); + var evt = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, + new MessageTemplate(Enumerable.Empty()), Enumerable.Empty(), + traceId, spanId); + var json = AssertValidJson(log => log.Write(evt)); + Assert.Equal(traceId.ToHexString(), json["@tr"]); + Assert.Equal(spanId.ToHexString(), json["@sp"]); + } + + [Fact] + public void RecognizesSerilogTracingProperties() + { + Activity.DefaultIdFormat = ActivityIdFormat.W3C; + + using var source = new ActivitySource(nameof(SeqCompactJsonFormatterTests)); + + using var listener = new ActivityListener(); + listener.ShouldListenTo = s => s.Name == source.Name; + listener.Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData; + ActivitySource.AddActivityListener(listener); + + using var parent = source.StartActivity(); + Assert.NotNull(parent); + + using var child = source.StartActivity(); + Assert.NotNull(child); + + var st = DateTime.UtcNow; + var tr = child.TraceId; + var sp = child.SpanId; + var ps = parent.SpanId; + const ActivityKind sk = ActivityKind.Server; + + var jObject = AssertValidJson(log => log.Information("{SpanStartTimestamp} {ParentSpanId} {SpanKind}", st, ps, sk)); + + Assert.False(jObject.ContainsKey("SpanStartTimestamp")); + + Assert.True(jObject.TryGetValue("@st", out var stValue)); + Assert.Equal(st.ToString("o"), stValue.ToObject()); + + Assert.True(jObject.TryGetValue("@tr", out var trValue)); + Assert.Equal(tr.ToHexString(), trValue.ToObject()); + + Assert.True(jObject.TryGetValue("@sp", out var spValue)); + Assert.Equal(sp.ToHexString(), spValue.ToObject()); + + Assert.True(jObject.TryGetValue("@ps", out var psValue)); + Assert.Equal(ps.ToHexString(), psValue.ToObject()); + + Assert.True(jObject.TryGetValue("@sk", out var skValue)); + Assert.Equal("Server", skValue.ToObject()); + } + + [Fact] + public void IgnoresSerilogTracingPropertiesWhenNotTracing() + { + var st = DateTime.UtcNow; + var jObject = AssertValidJson(log => log.Information("{SpanStartTimestamp}", st)); + + Assert.True(jObject.ContainsKey("SpanStartTimestamp")); + + Assert.False(jObject.ContainsKey("@st")); + } +} \ No newline at end of file diff --git a/test/Serilog.Sinks.Seq.Tests/Serilog.Sinks.Seq.Tests.csproj b/test/Serilog.Sinks.Seq.Tests/Serilog.Sinks.Seq.Tests.csproj index 7be995d..acb05e2 100644 --- a/test/Serilog.Sinks.Seq.Tests/Serilog.Sinks.Seq.Tests.csproj +++ b/test/Serilog.Sinks.Seq.Tests/Serilog.Sinks.Seq.Tests.csproj @@ -1,6 +1,6 @@  - net4.8;net7.0 + net4.8;net8.0 Serilog.Sinks.Seq.Tests ../../assets/Serilog.snk true @@ -10,7 +10,7 @@ enable - + $(DefineConstants);ASYNC_DISPOSE @@ -28,18 +28,19 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + - +