Skip to content

Commit bc5c060

Browse files
Flash0vergetsentry-botjamescrosswell
authored
feat: add Serilog integration (#4462)
Co-authored-by: Sentry Github Bot <[email protected]> Co-authored-by: James Crosswell <[email protected]>
1 parent 2dc1551 commit bc5c060

22 files changed

+535
-39
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Features
6+
7+
- Add (experimental) _Structured Logs_ integration for `Serilog` ([#4462](https://github.com/getsentry/sentry-dotnet/pull/4462))
8+
59
### Fixes
610

711
- Upload linked PDBs to fix non-IL-stripped symbolication for iOS ([#4527](https://github.com/getsentry/sentry-dotnet/pull/4527))

samples/Sentry.Samples.Serilog/Program.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ private static void Main()
2525
// Error and higher is sent as event (default is Error)
2626
options.MinimumEventLevel = LogEventLevel.Error;
2727
options.AttachStacktrace = true;
28+
// send structured logs to Sentry
29+
options.Experimental.EnableLogs = true;
2830
// send PII like the username of the user logged in to the device
2931
options.SendDefaultPii = true;
3032
// Optional Serilog text formatter used to format LogEvent to string. If TextFormatter is set, FormatProvider is ignored.

src/Sentry.Serilog/LogLevelExtensions.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,18 @@ public static BreadcrumbLevel ToBreadcrumbLevel(this LogEventLevel level)
4242
_ => (BreadcrumbLevel)level
4343
};
4444
}
45+
46+
public static SentryLogLevel ToSentryLogLevel(this LogEventLevel level)
47+
{
48+
return level switch
49+
{
50+
LogEventLevel.Verbose => SentryLogLevel.Trace,
51+
LogEventLevel.Debug => SentryLogLevel.Debug,
52+
LogEventLevel.Information => SentryLogLevel.Info,
53+
LogEventLevel.Warning => SentryLogLevel.Warning,
54+
LogEventLevel.Error => SentryLogLevel.Error,
55+
LogEventLevel.Fatal => SentryLogLevel.Fatal,
56+
_ => (SentryLogLevel)level,
57+
};
58+
}
4559
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
using Sentry.Internal.Extensions;
2+
using Serilog.Parsing;
3+
4+
namespace Sentry.Serilog;
5+
6+
internal sealed partial class SentrySink
7+
{
8+
private static void CaptureStructuredLog(IHub hub, SentryOptions options, LogEvent logEvent, string formatted, string? template)
9+
{
10+
GetTraceIdAndSpanId(hub, out var traceId, out var spanId);
11+
GetStructuredLoggingParametersAndAttributes(logEvent, out var parameters, out var attributes);
12+
13+
SentryLog log = new(logEvent.Timestamp, traceId, logEvent.Level.ToSentryLogLevel(), formatted)
14+
{
15+
Template = template,
16+
Parameters = parameters,
17+
ParentSpanId = spanId,
18+
};
19+
20+
log.SetDefaultAttributes(options, Sdk);
21+
22+
foreach (var attribute in attributes)
23+
{
24+
log.SetAttribute(attribute.Key, attribute.Value);
25+
}
26+
27+
hub.Logger.CaptureLog(log);
28+
}
29+
30+
private static void GetTraceIdAndSpanId(IHub hub, out SentryId traceId, out SpanId? spanId)
31+
{
32+
var span = hub.GetSpan();
33+
if (span is not null)
34+
{
35+
traceId = span.TraceId;
36+
spanId = span.SpanId;
37+
return;
38+
}
39+
40+
var scope = hub.GetScope();
41+
if (scope is not null)
42+
{
43+
traceId = scope.PropagationContext.TraceId;
44+
spanId = scope.PropagationContext.SpanId;
45+
return;
46+
}
47+
48+
traceId = SentryId.Empty;
49+
spanId = null;
50+
}
51+
52+
private static void GetStructuredLoggingParametersAndAttributes(LogEvent logEvent, out ImmutableArray<KeyValuePair<string, object>> parameters, out List<KeyValuePair<string, object>> attributes)
53+
{
54+
var propertyNames = new HashSet<string>();
55+
foreach (var token in logEvent.MessageTemplate.Tokens)
56+
{
57+
if (token is PropertyToken property)
58+
{
59+
propertyNames.Add(property.PropertyName);
60+
}
61+
}
62+
63+
var @params = ImmutableArray.CreateBuilder<KeyValuePair<string, object>>();
64+
attributes = new List<KeyValuePair<string, object>>();
65+
66+
foreach (var property in logEvent.Properties)
67+
{
68+
if (propertyNames.Contains(property.Key))
69+
{
70+
foreach (var parameter in GetLogEventProperties(property))
71+
{
72+
@params.Add(parameter);
73+
}
74+
}
75+
else
76+
{
77+
foreach (var attribute in GetLogEventProperties(property))
78+
{
79+
attributes.Add(new KeyValuePair<string, object>($"property.{attribute.Key}", attribute.Value));
80+
}
81+
}
82+
}
83+
84+
parameters = @params.DrainToImmutable();
85+
return;
86+
87+
static IEnumerable<KeyValuePair<string, object>> GetLogEventProperties(KeyValuePair<string, LogEventPropertyValue> property)
88+
{
89+
if (property.Value is ScalarValue scalarValue)
90+
{
91+
if (scalarValue.Value is not null)
92+
{
93+
yield return new KeyValuePair<string, object>(property.Key, scalarValue.Value);
94+
}
95+
}
96+
else if (property.Value is SequenceValue sequenceValue)
97+
{
98+
if (sequenceValue.Elements.Count != 0)
99+
{
100+
yield return new KeyValuePair<string, object>(property.Key, sequenceValue.ToString());
101+
}
102+
}
103+
else if (property.Value is DictionaryValue dictionaryValue)
104+
{
105+
if (dictionaryValue.Elements.Count != 0)
106+
{
107+
yield return new KeyValuePair<string, object>(property.Key, dictionaryValue.ToString());
108+
}
109+
}
110+
else if (property.Value is StructureValue structureValue)
111+
{
112+
foreach (var prop in structureValue.Properties)
113+
{
114+
if (LogEventProperty.IsValidName(prop.Name))
115+
{
116+
yield return new KeyValuePair<string, object>($"{property.Key}.{prop.Name}", prop.Value.ToString());
117+
}
118+
}
119+
}
120+
else if (!property.Value.IsNull())
121+
{
122+
yield return new KeyValuePair<string, object>(property.Key, property.Value);
123+
}
124+
}
125+
}
126+
}

src/Sentry.Serilog/SentrySink.cs

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,20 @@ namespace Sentry.Serilog;
55
/// </summary>
66
/// <inheritdoc cref="IDisposable" />
77
/// <inheritdoc cref="ILogEventSink" />
8-
internal sealed class SentrySink : ILogEventSink, IDisposable
8+
internal sealed partial class SentrySink : ILogEventSink, IDisposable
99
{
1010
private readonly IDisposable? _sdkDisposable;
1111
private readonly SentrySerilogOptions _options;
1212

1313
internal static readonly SdkVersion NameAndVersion
1414
= typeof(SentrySink).Assembly.GetNameAndVersion();
1515

16+
private static readonly SdkVersion Sdk = new()
17+
{
18+
Name = SdkName,
19+
Version = NameAndVersion.Version,
20+
};
21+
1622
/// <summary>
1723
/// Serilog SDK name.
1824
/// </summary>
@@ -50,6 +56,11 @@ internal SentrySink(
5056

5157
public void Emit(LogEvent logEvent)
5258
{
59+
if (!IsEnabled(logEvent))
60+
{
61+
return;
62+
}
63+
5364
if (isReentrant.Value)
5465
{
5566
_options.DiagnosticLogger?.LogError($"Reentrant log event detected. Logging when inside the scope of another log event can cause a StackOverflowException. LogEventInfo.Message: {logEvent.MessageTemplate.Text}");
@@ -67,6 +78,15 @@ public void Emit(LogEvent logEvent)
6778
}
6879
}
6980

81+
private bool IsEnabled(LogEvent logEvent)
82+
{
83+
var options = _hubAccessor().GetSentryOptions();
84+
85+
return logEvent.Level >= _options.MinimumEventLevel
86+
|| logEvent.Level >= _options.MinimumBreadcrumbLevel
87+
|| options?.Experimental.EnableLogs is true;
88+
}
89+
7090
private void InnerEmit(LogEvent logEvent)
7191
{
7292
if (logEvent.TryGetSourceContext(out var context))
@@ -77,8 +97,7 @@ private void InnerEmit(LogEvent logEvent)
7797
}
7898
}
7999

80-
var hub = _hubAccessor();
81-
if (hub is null || !hub.IsEnabled)
100+
if (_hubAccessor() is not { IsEnabled: true } hub)
82101
{
83102
return;
84103
}
@@ -122,30 +141,37 @@ private void InnerEmit(LogEvent logEvent)
122141
}
123142
}
124143

125-
if (logEvent.Level < _options.MinimumBreadcrumbLevel)
144+
if (logEvent.Level >= _options.MinimumBreadcrumbLevel)
126145
{
127-
return;
146+
Dictionary<string, string>? data = null;
147+
if (exception != null && !string.IsNullOrWhiteSpace(formatted))
148+
{
149+
// Exception.Message won't be used as Breadcrumb message
150+
// Avoid losing it by adding as data:
151+
data = new Dictionary<string, string>
152+
{
153+
{ "exception_message", exception.Message }
154+
};
155+
}
156+
157+
hub.AddBreadcrumb(
158+
_clock,
159+
string.IsNullOrWhiteSpace(formatted)
160+
? exception?.Message ?? ""
161+
: formatted,
162+
context,
163+
data: data,
164+
level: logEvent.Level.ToBreadcrumbLevel());
128165
}
129166

130-
Dictionary<string, string>? data = null;
131-
if (exception != null && !string.IsNullOrWhiteSpace(formatted))
167+
// Read the options from the Hub, rather than the Sink's Serilog-Options, because 'EnableLogs' is declared in the base 'SentryOptions', rather than the derived 'SentrySerilogOptions'.
168+
// In cases where Sentry's Serilog-Sink is added without a DSN (i.e., without initializing the SDK) and the SDK is initialized differently (e.g., through ASP.NET Core),
169+
// then the 'EnableLogs' option of this Sink's Serilog-Options is default, but the Hub's Sentry-Options have the actual user-defined value configured.
170+
var options = hub.GetSentryOptions();
171+
if (options?.Experimental.EnableLogs is true)
132172
{
133-
// Exception.Message won't be used as Breadcrumb message
134-
// Avoid losing it by adding as data:
135-
data = new Dictionary<string, string>
136-
{
137-
{"exception_message", exception.Message}
138-
};
173+
CaptureStructuredLog(hub, options, logEvent, formatted, template);
139174
}
140-
141-
hub.AddBreadcrumb(
142-
_clock,
143-
string.IsNullOrWhiteSpace(formatted)
144-
? exception?.Message ?? ""
145-
: formatted,
146-
context,
147-
data: data,
148-
level: logEvent.Level.ToBreadcrumbLevel());
149175
}
150176

151177
private static bool IsSentryContext(string context) =>

src/Sentry.Serilog/SentrySinkExtensions.cs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ public static class SentrySinkExtensions
1313
/// </summary>
1414
/// <param name="loggerConfiguration">The logger configuration .<seealso cref="LoggerSinkConfiguration"/></param>
1515
/// <param name="dsn">The Sentry DSN (required). <seealso cref="SentryOptions.Dsn"/></param>
16-
/// <param name="minimumEventLevel">Minimum log level to send an event. <seealso cref="SentrySerilogOptions.MinimumEventLevel"/></param>
1716
/// <param name="minimumBreadcrumbLevel">Minimum log level to record a breadcrumb. <seealso cref="SentrySerilogOptions.MinimumBreadcrumbLevel"/></param>
17+
/// <param name="minimumEventLevel">Minimum log level to send an event. <seealso cref="SentrySerilogOptions.MinimumEventLevel"/></param>
1818
/// <param name="formatProvider">The Serilog format provider. <seealso cref="IFormatProvider"/></param>
1919
/// <param name="textFormatter">The Serilog text formatter. <seealso cref="ITextFormatter"/></param>
2020
/// <param name="sendDefaultPii">Whether to include default Personal Identifiable information. <seealso cref="SentryOptions.SendDefaultPii"/></param>
@@ -35,6 +35,7 @@ public static class SentrySinkExtensions
3535
/// <param name="reportAssembliesMode">What mode to use for reporting referenced assemblies in each event sent to sentry. Defaults to <see cref="Sentry.ReportAssembliesMode.Version"/></param>
3636
/// <param name="deduplicateMode">What modes to use for event automatic de-duplication. <seealso cref="SentryOptions.DeduplicateMode"/></param>
3737
/// <param name="defaultTags">Default tags to add to all events. <seealso cref="SentryOptions.DefaultTags"/></param>
38+
/// <param name="experimentalEnableLogs">Whether to send structured logs. <seealso cref="SentryOptions.SentryExperimentalOptions.EnableLogs"/></param>
3839
/// <returns><see cref="LoggerConfiguration"/></returns>
3940
/// <example>This sample shows how each item may be set from within a configuration file:
4041
/// <code>
@@ -50,7 +51,7 @@ public static class SentrySinkExtensions
5051
/// "dsn": "https://[email protected]",
5152
/// "minimumBreadcrumbLevel": "Verbose",
5253
/// "minimumEventLevel": "Error",
53-
/// "outputTemplate": "{Timestamp:o} [{Level:u3}] ({Application}/{MachineName}/{ThreadId}) {Message}{NewLine}{Exception}"///
54+
/// "outputTemplate": "{Timestamp:o} [{Level:u3}] ({Application}/{MachineName}/{ThreadId}) {Message}{NewLine}{Exception}",
5455
/// "sendDefaultPii": false,
5556
/// "isEnvironmentUser": false,
5657
/// "serverName": "MyServerName",
@@ -71,7 +72,8 @@ public static class SentrySinkExtensions
7172
/// "defaultTags": {
7273
/// "key-1", "value-1",
7374
/// "key-2", "value-2"
74-
/// }
75+
/// },
76+
/// "experimentalEnableLogs": true
7577
/// }
7678
/// }
7779
/// ]
@@ -103,7 +105,8 @@ public static LoggerConfiguration Sentry(
103105
SentryLevel? diagnosticLevel = null,
104106
ReportAssembliesMode? reportAssembliesMode = null,
105107
DeduplicateMode? deduplicateMode = null,
106-
Dictionary<string, string>? defaultTags = null)
108+
Dictionary<string, string>? defaultTags = null,
109+
bool? experimentalEnableLogs = null)
107110
{
108111
return loggerConfiguration.Sentry(o => ConfigureSentrySerilogOptions(o,
109112
dsn,
@@ -128,7 +131,8 @@ public static LoggerConfiguration Sentry(
128131
diagnosticLevel,
129132
reportAssembliesMode,
130133
deduplicateMode,
131-
defaultTags));
134+
defaultTags,
135+
experimentalEnableLogs));
132136
}
133137

134138
/// <summary>
@@ -157,7 +161,7 @@ public static LoggerConfiguration Sentry(
157161
/// "Args": {
158162
/// "minimumEventLevel": "Error",
159163
/// "minimumBreadcrumbLevel": "Verbose",
160-
/// "outputTemplate": "{Timestamp:o} [{Level:u3}] ({Application}/{MachineName}/{ThreadId}) {Message}{NewLine}{Exception}"///
164+
/// "outputTemplate": "{Timestamp:o} [{Level:u3}] ({Application}/{MachineName}/{ThreadId}) {Message}{NewLine}{Exception}"
161165
/// }
162166
/// }
163167
/// ]
@@ -205,7 +209,8 @@ internal static void ConfigureSentrySerilogOptions(
205209
SentryLevel? diagnosticLevel = null,
206210
ReportAssembliesMode? reportAssembliesMode = null,
207211
DeduplicateMode? deduplicateMode = null,
208-
Dictionary<string, string>? defaultTags = null)
212+
Dictionary<string, string>? defaultTags = null,
213+
bool? experimentalEnableLogs = null)
209214
{
210215
if (dsn is not null)
211216
{
@@ -317,6 +322,11 @@ internal static void ConfigureSentrySerilogOptions(
317322
sentrySerilogOptions.DeduplicateMode = deduplicateMode.Value;
318323
}
319324

325+
if (experimentalEnableLogs.HasValue)
326+
{
327+
sentrySerilogOptions.Experimental.EnableLogs = experimentalEnableLogs.Value;
328+
}
329+
320330
// Serilog-specific items
321331
sentrySerilogOptions.InitializeSdk = dsn is not null; // Inferred from the Sentry overload that is used
322332
if (defaultTags?.Count > 0)
@@ -354,7 +364,6 @@ public static LoggerConfiguration Sentry(
354364
sdkDisposable = SentrySdk.Init(options);
355365
}
356366

357-
var minimumOverall = (LogEventLevel)Math.Min((int)options.MinimumBreadcrumbLevel, (int)options.MinimumEventLevel);
358-
return loggerConfiguration.Sink(new SentrySink(options, sdkDisposable), minimumOverall);
367+
return loggerConfiguration.Sink(new SentrySink(options, sdkDisposable));
359368
}
360369
}

src/Sentry/SentryLog.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ namespace Sentry;
99
/// <para>This API is experimental and it may change in the future.</para>
1010
/// </summary>
1111
[Experimental(DiagnosticId.ExperimentalFeature)]
12+
[DebuggerDisplay(@"SentryLog \{ Level = {Level}, Message = '{Message}' \}")]
1213
public sealed class SentryLog
1314
{
1415
private readonly Dictionary<string, SentryAttribute> _attributes;

test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ namespace Serilog
4646
Sentry.SentryLevel? diagnosticLevel = default,
4747
Sentry.ReportAssembliesMode? reportAssembliesMode = default,
4848
Sentry.DeduplicateMode? deduplicateMode = default,
49-
System.Collections.Generic.Dictionary<string, string>? defaultTags = null) { }
49+
System.Collections.Generic.Dictionary<string, string>? defaultTags = null,
50+
bool? experimentalEnableLogs = default) { }
5051
}
5152
}

0 commit comments

Comments
 (0)