diff --git a/src/Serilog.Sinks.Splunk/Sinks/Splunk/CompactSplunkJsonFormatter.cs b/src/Serilog.Sinks.Splunk/Sinks/Splunk/CompactSplunkJsonFormatter.cs new file mode 100644 index 0000000..79132fa --- /dev/null +++ b/src/Serilog.Sinks.Splunk/Sinks/Splunk/CompactSplunkJsonFormatter.cs @@ -0,0 +1,137 @@ +// 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 Serilog.Events; +using Serilog.Formatting; +using Serilog.Formatting.Json; +using Serilog.Parsing; +using System; +using System.Globalization; +using System.IO; +using System.Linq; + +namespace Serilog.Sinks.Splunk +{ + /// + /// Renders log events into a Compact JSON format for consumption by Splunk. + /// + public class CompactSplunkJsonFormatter : ITextFormatter + { + private static readonly JsonValueFormatter ValueFormatter = new JsonValueFormatter(typeTagName: "$type"); + private readonly string _suffix; + private readonly bool _renderTemplate; + + /// + /// Construct a . + /// + /// The source of the event + /// The source type of the event + /// The host of the event + /// The Splunk index to log to + /// If true, the template used will be rendered and written to the output as a property named MessageTemplate + public CompactSplunkJsonFormatter(bool renderTemplate = false, string source = null, string sourceType = null, string host = null, string index = null) + { + _renderTemplate = renderTemplate; + var suffixWriter = new StringWriter(); + suffixWriter.Write("}"); // Terminates "event" + + if (!string.IsNullOrWhiteSpace(source)) + { + suffixWriter.Write(",\"source\":"); + JsonValueFormatter.WriteQuotedJsonString(source, suffixWriter); + } + + if (!string.IsNullOrWhiteSpace(sourceType)) + { + suffixWriter.Write(",\"sourcetype\":"); + JsonValueFormatter.WriteQuotedJsonString(sourceType, suffixWriter); + } + + if (!string.IsNullOrWhiteSpace(host)) + { + suffixWriter.Write(",\"host\":"); + JsonValueFormatter.WriteQuotedJsonString(host, suffixWriter); + } + + if (!string.IsNullOrWhiteSpace(index)) + { + suffixWriter.Write(",\"index\":"); + JsonValueFormatter.WriteQuotedJsonString(index, suffixWriter); + } + suffixWriter.Write('}'); // Terminates the payload + _suffix = suffixWriter.ToString(); + } + + /// + public void Format(LogEvent logEvent, TextWriter output) + { + if (logEvent == null) throw new ArgumentNullException(nameof(logEvent)); + if (output == null) throw new ArgumentNullException(nameof(output)); + + output.Write("{\"time\":\""); + output.Write(logEvent.Timestamp.ToEpoch().ToString(CultureInfo.InvariantCulture)); + output.Write("\",\"event\":{\"@l\":\""); + output.Write(logEvent.Level); + output.Write('"'); + + if (_renderTemplate) + { + 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); + JsonValueFormatter.WriteQuotedJsonString(space.ToString(), output); + } + output.Write(']'); + } + } + if (logEvent.Exception != null) + { + output.Write(",\"@x\":"); + JsonValueFormatter.WriteQuotedJsonString(logEvent.Exception.ToString(), output); + } + + foreach (var property in logEvent.Properties) + { + var name = property.Key; + 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.WriteLine(_suffix); + } + } +} \ No newline at end of file diff --git a/test/Serilog.Sinks.Splunk.Tests/CompactSplunkJsonFormatterTests.cs b/test/Serilog.Sinks.Splunk.Tests/CompactSplunkJsonFormatterTests.cs new file mode 100644 index 0000000..58a1ffb --- /dev/null +++ b/test/Serilog.Sinks.Splunk.Tests/CompactSplunkJsonFormatterTests.cs @@ -0,0 +1,84 @@ +using Newtonsoft.Json.Linq; +using Serilog.Sinks.Splunk.Tests.Support; +using System; +using System.IO; +using Xunit; + +namespace Serilog.Sinks.Splunk.Tests +{ + public class CompactSplunkJsonFormatterTests + { + private void AssertValidJson(Action act, + string source = "", + string sourceType = "", + string host = "", + string index = "") + { + StringWriter outputRendered = new StringWriter(), output = new StringWriter(); + var log = new LoggerConfiguration() + .WriteTo.Sink(new TextWriterSink(output, new CompactSplunkJsonFormatter(false, source, sourceType, host, index))) + .WriteTo.Sink(new TextWriterSink(outputRendered, new CompactSplunkJsonFormatter(true, source, sourceType, host, index))) + .CreateLogger(); + + act(log); + + // Unfortunately this will not detect all JSON formatting issues; better than nothing however. + JObject.Parse(output.ToString()); + JObject.Parse(outputRendered.ToString()); + } + + [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 AMinimalEventWithSourceIsValidJson() + { + AssertValidJson(log => log.Information("One {Property}", 42), source: "A Test Source"); + } + + [Fact] + public void AMinimalEventWithSourceTypeIsValidJson() + { + AssertValidJson(log => log.Information("One {Property}", 42), sourceType: "A Test SourceType"); + } + + [Fact] + public void AMinimalEventWithHostIsValidJson() + { + AssertValidJson(log => log.Information("One {Property}", 42), host: "A Test Host"); + } + + [Fact] + public void AMinimalEventWithIndexIsValidJson() + { + AssertValidJson(log => log.Information("One {Property}", 42), host: "testindex"); + } + } +} \ No newline at end of file