Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
}
}
}]
}
```
112 changes: 67 additions & 45 deletions src/Redbox.Serilog.Stackdriver.Tests/StackdriverFormatterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -12,94 +14,114 @@ 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<string>("message") == propertyValue);
}

[Fact]
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<string>("@type")
);

// Report location
Assert.Equal("the source context", log.SelectToken("context.reportLocation.filePath")?.Value<string>());

// Service context
var assemblyName = Assembly.GetEntryAssembly()?.GetName();
Assert.Equal(assemblyName?.Name, log.SelectToken("serviceContext.service")?.Value<string>());
Assert.Equal(assemblyName?.Version?.ToString(), log.SelectToken("serviceContext.version")?.Value<string>());
}

/// <summary>
/// Gets a log line in json format as a dictionary of string pairs
/// </summary>
/// <param name="log"></param>
/// <returns></returns>
private Dictionary<string, string> GetLogLineAsDictionary(string log)
private string[] SplitLogLogs(string logLines)
{
return JsonConvert.DeserializeObject<Dictionary<string, string>>(log);
return logLines.Split("\n").Where(l => !string.IsNullOrWhiteSpace(l)).ToArray();
}

/// <summary>
/// Asserts required fields in log output are set and have valid values
/// </summary>
/// <param name="logDict"></param>
/// <param name="log"></param>
/// <param name="hasException"></param>
private void AssertValidLogLine(Dictionary<string, string> 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<string>("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<DateTime>("timestamp").ToString("O"), timestamp);

Assert.True(log.ContainsKey("fingerprint"));
Assert.NotEmpty(log.Value<string>("fingerprint"));

Assert.True(log.ContainsKey("severity"));
Assert.NotEmpty(log.Value<string>("severity"));

Assert.True(log.ContainsKey(("MessageTemplate")));
Assert.NotEmpty(log.Value<string>("MessageTemplate"));

if (hasException)
{
Assert.True(logDict.ContainsKey("exception"));
Assert.NotEmpty(logDict["exception"]);
Assert.True(log.ContainsKey("exception"));
Assert.NotEmpty(log.Value<string>("exception"));
}
}
}
}
}
38 changes: 37 additions & 1 deletion src/Redbox.Serilog.Stackdriver/StackdriverJsonFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
using Serilog.Parsing;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;

namespace Redbox.Serilog.Stackdriver
{
Expand All @@ -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());
}

/// <summary>
Expand Down Expand Up @@ -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)
Expand Down