diff --git a/README.md b/README.md index a1d21f7..fcb26ec 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Be sure to add `.ReadFrom.Configuration(configuration)` to your Serilog setup fi ### Configuration Options -The class `StackdriverJsonFormatter` has two optional arguments: +The class `StackdriverJsonFormatter` has some optional arguments: #### checkForPayloadLimit @@ -61,6 +61,40 @@ Stackdriver will break the long line into multiple lines, which will break searc Default `true`. If the Serilog Message Template should be included in the logs, e.g. ` { ... "MessageTemplate" : "Hello from {name:l}" ... }` +#### markErrorsForErrorReporting +Default `false`. If the `@type` property of the logs should be set to `type.googleapis.com/google.devtools.clouderrorreporting.v1beta1.ReportedErrorEvent` when the log level is Error or above. +This causes GCP to send these logs to Cloud Error Reporting. See [documentation](https://cloud.google.com/error-reporting/docs/grouping-errors). + +#### serviceName +Defaults to the executing assembly name. Sets the service name used by GCP error reporting. + +#### serviceVersion +Defaults to the executing assembly version. Sets the service version used by GCP error reporting. + +#### markErrorsForErrorReporting +Default `false`. If the `@type` property of the logs should be set to `type.googleapis.com/google.devtools.clouderrorreporting.v1beta1.ReportedErrorEvent` when the log level is Error or above. +This causes GCP to send these logs to Cloud Error Reporting. See [documentation](https://cloud.google.com/error-reporting/docs/grouping-errors). + #### valueFormatter Defaults to `new JsonValueFormatter(typeTagName: "$type")`. A valid Serilog JSON Formatter. + +Options can be passed in the `StackdriverJsonFormatter` constructor, or set in appsettings.json: +```json +"Serilog": { + "Using": [ + "Serilog.Sinks.Console" + ], + "WriteTo": [ + { + "Name": "Console", + "Args": { + "formatter": { + "type": "Redbox.Serilog.Stackdriver.StackdriverJsonFormatter, Redbox.Serilog.Stackdriver", + "markErrorsForErrorReporting": true, + "includeMessageTemplate": false + } + } + }] +} +``` diff --git a/src/Redbox.Serilog.Stackdriver.Tests/StackdriverFormatterTests.cs b/src/Redbox.Serilog.Stackdriver.Tests/StackdriverFormatterTests.cs index 492809c..c965f2b 100644 --- a/src/Redbox.Serilog.Stackdriver.Tests/StackdriverFormatterTests.cs +++ b/src/Redbox.Serilog.Stackdriver.Tests/StackdriverFormatterTests.cs @@ -2,7 +2,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Serilog.Events; using Serilog.Parsing; using Xunit; @@ -12,23 +14,23 @@ namespace Redbox.Serilog.Stackdriver.Tests public class StackdriverFormatterTests { private static readonly DateTimeOffset DateTimeOffset = DateTimeOffset.Now; - + [Fact] public void Test_StackdriverFormatter_Format() { var propertyName = "greeting"; var propertyValue = "hello"; - var logEvent = new LogEvent(DateTimeOffset, LogEventLevel.Debug, new Exception(), - new MessageTemplate("{greeting}", - new MessageTemplateToken[] { new PropertyToken(propertyName, propertyValue, "l") }), + var logEvent = new LogEvent(DateTimeOffset, LogEventLevel.Debug, new Exception(), + new MessageTemplate("{greeting}", + new MessageTemplateToken[] { new PropertyToken(propertyName, propertyValue, "l") }), new LogEventProperty[0]); - + using var writer = new StringWriter(); new StackdriverJsonFormatter().Format(logEvent, writer); - var logDict = GetLogLineAsDictionary(writer.ToString()); - - AssertValidLogLine(logDict); - Assert.True(logDict["message"] == propertyValue); + var log = JObject.Parse(writer.ToString()); + + AssertValidLogLine(log); + Assert.True(log.Value("message") == propertyValue); } [Fact] @@ -36,70 +38,90 @@ public void Test_StackdrvierFormatter_FormatLong() { // Creates a large string > 200kb var token = new TextToken(new string('*', 51200)); - var logEvent = new LogEvent(DateTimeOffset, LogEventLevel.Debug, - new Exception(), new MessageTemplate("{0}", new MessageTemplateToken[] { token }), + var logEvent = new LogEvent(DateTimeOffset, LogEventLevel.Debug, + new Exception(), new MessageTemplate("{0}", new MessageTemplateToken[] { token }), new LogEventProperty[0]); - + using var writer = new StringWriter(); new StackdriverJsonFormatter().Format(logEvent, writer); var lines = SplitLogLogs(writer.ToString()); - + // The log created was longer than Stackdriver's soft limit of 256 bytes // This means the json will be spread out onto two lines, breaking search // In this scenario the library should add an additional log event informing // the user of this issue Assert.True(lines.Length == 2); // Validate each line is valid json - var ourLogLineDict = GetLogLineAsDictionary(lines[0]); - AssertValidLogLine(ourLogLineDict); - var errorLogLineDict = GetLogLineAsDictionary(lines[1]); - AssertValidLogLine(errorLogLineDict, hasException: false); + var ourLogLine = JObject.Parse(lines[0]); + AssertValidLogLine(ourLogLine); + var errorLogLine = JObject.Parse(lines[1]); + AssertValidLogLine(errorLogLine, hasException: false); } - private string[] SplitLogLogs(string logLines) + [Fact] + public void Test_StackdriverFormatter_MarkErrorsForErrorReporting() { - return logLines.Split("\n").Where(l => !string.IsNullOrWhiteSpace(l)).ToArray(); + // Creates a large string > 200kb + var token = new TextToken("test error"); + var logEvent = new LogEvent(DateTimeOffset, LogEventLevel.Error, + null, new MessageTemplate("{0}", new MessageTemplateToken[] { token }), + new[] { new LogEventProperty("SourceContext", new ScalarValue("the source context")) }); + + using var writer = new StringWriter(); + new StackdriverJsonFormatter(markErrorsForErrorReporting: true).Format(logEvent, writer); + var log = JObject.Parse(writer.ToString()); + + AssertValidLogLine(log, false); + + // @type + Assert.Equal( + "type.googleapis.com/google.devtools.clouderrorreporting.v1beta1.ReportedErrorEvent", + log.Value("@type") + ); + + // Report location + Assert.Equal("the source context", log.SelectToken("context.reportLocation.filePath")?.Value()); + + // Service context + var assemblyName = Assembly.GetEntryAssembly()?.GetName(); + Assert.Equal(assemblyName?.Name, log.SelectToken("serviceContext.service")?.Value()); + Assert.Equal(assemblyName?.Version?.ToString(), log.SelectToken("serviceContext.version")?.Value()); } - /// - /// Gets a log line in json format as a dictionary of string pairs - /// - /// - /// - private Dictionary GetLogLineAsDictionary(string log) + private string[] SplitLogLogs(string logLines) { - return JsonConvert.DeserializeObject>(log); + return logLines.Split("\n").Where(l => !string.IsNullOrWhiteSpace(l)).ToArray(); } /// /// Asserts required fields in log output are set and have valid values /// - /// + /// /// - private void AssertValidLogLine(Dictionary logDict, + private void AssertValidLogLine(JObject log, bool hasException = true) { - Assert.True(logDict.ContainsKey("message")); - Assert.NotEmpty(logDict["message"]); - - Assert.True(logDict.ContainsKey("timestamp")); + Assert.True(log.ContainsKey("message")); + Assert.NotEmpty(log.Value("message")); + + Assert.True(log.ContainsKey("timestamp")); var timestamp = DateTimeOffset.UtcDateTime.ToString("O"); - Assert.Equal(logDict["timestamp"], timestamp); - - Assert.True(logDict.ContainsKey("fingerprint")); - Assert.NotEmpty(logDict["fingerprint"]); - - Assert.True(logDict.ContainsKey("severity")); - Assert.NotEmpty(logDict["severity"]); - - Assert.True(logDict.ContainsKey(("MessageTemplate"))); - Assert.NotEmpty(logDict["MessageTemplate"]); + Assert.Equal(log.Value("timestamp").ToString("O"), timestamp); + + Assert.True(log.ContainsKey("fingerprint")); + Assert.NotEmpty(log.Value("fingerprint")); + + Assert.True(log.ContainsKey("severity")); + Assert.NotEmpty(log.Value("severity")); + + Assert.True(log.ContainsKey(("MessageTemplate"))); + Assert.NotEmpty(log.Value("MessageTemplate")); if (hasException) { - Assert.True(logDict.ContainsKey("exception")); - Assert.NotEmpty(logDict["exception"]); + Assert.True(log.ContainsKey("exception")); + Assert.NotEmpty(log.Value("exception")); } } } -} +} \ No newline at end of file diff --git a/src/Redbox.Serilog.Stackdriver/StackdriverJsonFormatter.cs b/src/Redbox.Serilog.Stackdriver/StackdriverJsonFormatter.cs index 3dba015..c68f8ee 100644 --- a/src/Redbox.Serilog.Stackdriver/StackdriverJsonFormatter.cs +++ b/src/Redbox.Serilog.Stackdriver/StackdriverJsonFormatter.cs @@ -26,6 +26,7 @@ using Serilog.Parsing; using System.Collections.Generic; using System.Linq; +using System.Reflection; namespace Redbox.Serilog.Stackdriver { @@ -40,15 +41,26 @@ public class StackdriverJsonFormatter : ITextFormatter private readonly bool _checkForPayloadLimit; private readonly bool _includeMessageTemplate; + private readonly bool _markErrorsForErrorReporting; private readonly JsonValueFormatter _valueFormatter; + private readonly LogEventPropertyValue _assemblyVersion; + private readonly LogEventPropertyValue _assemblyName; public StackdriverJsonFormatter(bool checkForPayloadLimit = true, bool includeMessageTemplate = true, - JsonValueFormatter valueFormatter = null) + JsonValueFormatter valueFormatter = null, + bool markErrorsForErrorReporting = false, + string serviceName = null, + string serviceVersion = null) { _checkForPayloadLimit = checkForPayloadLimit; _includeMessageTemplate = includeMessageTemplate; + _markErrorsForErrorReporting = markErrorsForErrorReporting; _valueFormatter = valueFormatter ?? new JsonValueFormatter(typeTagName: "$type"); + + var assemblyName = Assembly.GetEntryAssembly()?.GetName(); + _assemblyName = new ScalarValue(serviceName ?? assemblyName?.Name); + _assemblyVersion = new ScalarValue(serviceVersion ?? assemblyName?.Version.ToString()); } /// @@ -118,6 +130,30 @@ public void FormatEvent(LogEvent logEvent, TextWriter originalOutput, JsonValueF output.Write(",\"MessageTemplate\":"); JsonValueFormatter.WriteQuotedJsonString(logEvent.MessageTemplate.Text, output); } + + // Give logs with severity: ERROR a type of ReportedErrorEvent + if (_markErrorsForErrorReporting && logEvent.Level >= LogEventLevel.Error) + { + // Set @type so that Cloud Error Reporting will recognize this error + output.Write(",\"@type\":"); + output.Write("\"type.googleapis.com/google.devtools.clouderrorreporting.v1beta1.ReportedErrorEvent\""); + + // If SourceContext is defined, set context.reportLocation to this value. + // We use filePath, the highest-level parameter of reportLocation, as it does not have a className parameter. + logEvent.Properties.TryGetValue("SourceContext", out var sourceContext); + if (sourceContext != null) + { + output.Write(",\"context\":{\"reportLocation\":{"); + WriteKeyValue(output, valueFormatter, "filePath", sourceContext, false); + output.Write("}}"); + } + + // Set the serviceContext + output.Write(",\"serviceContext\":{"); + WriteKeyValue(output, valueFormatter, "service", _assemblyName, false); + WriteKeyValue(output, valueFormatter, "version", _assemblyVersion); + output.Write("}"); + } // Custom Properties passed in by code logging foreach (var property in logEvent.Properties)