Skip to content

feat(logs): initial API for Sentry Logs #4158

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 51 commits into
base: feat/logs
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
0add668
feat(logs): initial experiment
Flash0ver Apr 30, 2025
db7d558
feat(logs): basic Logger Module API shape
Flash0ver Apr 30, 2025
d96b092
style(logs): consolidate
Flash0ver May 5, 2025
2958a47
ref(logs): remove generic WriteSerializable overload
Flash0ver May 5, 2025
a63371b
ref(logs): consolidate experimental Diagnostic-ID
Flash0ver May 5, 2025
d8d2567
feat(logs): add experimental options
Flash0ver May 7, 2025
fefd24c
Merge branch 'main' into feat/logs-initial-api
Flash0ver May 7, 2025
165996a
ref(logs): remove custom polyfills now provided through Polyfill
Flash0ver May 7, 2025
32e7e25
Format code
getsentry-bot May 7, 2025
fc88722
Merge branch 'feat/logs-initial-api' of https://github.com/getsentry/…
Flash0ver May 7, 2025
2ba87e4
ref(logs): move types out of Experimental namespace
Flash0ver May 7, 2025
0f1d4a4
feat(logs): change 'integer' from Int32 to Int64
Flash0ver May 7, 2025
8dec5d5
ref(logs): refine API surface area
Flash0ver May 8, 2025
a664f7e
ref(logs): match SeverityLevel to OTel spec
Flash0ver May 8, 2025
96693d0
ref(logs): rename SentrySeverity to LogSeverityLevel
Flash0ver May 8, 2025
83964cf
Format code
getsentry-bot May 8, 2025
dadc69b
ref(logs): hide underlying Dictionary`2 for Attributes
Flash0ver May 9, 2025
c91cdde
ref(logs): restructure attributes
Flash0ver May 9, 2025
0740c3b
ref(logs): extract TraceId and ParentSpanId methods
Flash0ver May 9, 2025
eee06bf
ref(logs): remove `SentryOptions.LogsSampleRate`
Flash0ver May 12, 2025
8c61d8b
feat(logs): support ISystemClock abstraction
Flash0ver May 12, 2025
80683ae
ref(logs): disambiguate SentryLogger names
Flash0ver May 12, 2025
cb20118
ref(logs): consolidate names of Log-Methods
Flash0ver May 12, 2025
2cb306f
ref(logs): rename LogSeverityLevel to SentryLogLevel
Flash0ver May 12, 2025
dcc0ec1
ref(logs): re-rename new logger type
Flash0ver May 13, 2025
58dce74
ref(logs): move Logger instances to Hubs
Flash0ver May 14, 2025
f2e1ba2
test(logs): add tests
Flash0ver May 14, 2025
0220015
Merge branch 'main' into feat/logs-initial-api
Flash0ver May 14, 2025
6822b23
Format code
getsentry-bot May 14, 2025
dd39fae
fix(logs): incorrectly serializing attributes
Flash0ver May 14, 2025
6eb5b9b
fix(logs): do not capture log when template/parameters are invalid
Flash0ver May 14, 2025
69c05b8
fix(logs): do not capture log on user callback exceptions
Flash0ver May 14, 2025
430cf82
ref(logs): move new public types to root namespace
Flash0ver May 14, 2025
31a8f1f
ref(logs): rework sample
Flash0ver May 14, 2025
2ae4476
ref(logs): ensure that DisabledHub dues not capture logs
Flash0ver May 14, 2025
69678ce
ref(logs): allow out-of-range Log-Level
Flash0ver May 14, 2025
97995a8
docs(logs): add XML comments indicating that logs will be ignored on …
Flash0ver May 14, 2025
fbe747d
docs(logs): add to changelog
Flash0ver May 15, 2025
64adf33
fix(logs): add to Bindable-Options
Flash0ver May 15, 2025
cdfa901
fix(logs): add to ApiApprovalTests
Flash0ver May 15, 2025
d2ac53b
test(logs): add missing net48 ApiApproval
Flash0ver May 15, 2025
4011ba6
test(logs): fix line endings on Windows
Flash0ver May 15, 2025
4ae82d0
Update src/Sentry/Protocol/Envelopes/Envelope.cs
Flash0ver May 15, 2025
bc1c465
Update SentryLog.cs
Flash0ver May 15, 2025
79fb190
test(logs): fix floating-point ToString expectation for .NET Framework
Flash0ver May 15, 2025
b4e80f4
ref(logs): remove some using declarations
Flash0ver May 15, 2025
b21adef
test(ci): trying to work around floating-point formatter on .NET Fram…
Flash0ver May 15, 2025
0032858
test(logs): skip failing tests on Mono (non-Windows)
Flash0ver May 15, 2025
a9769f8
test(log): fix Skip.If missing SkippableFact
Flash0ver May 15, 2025
9a51033
try: fix floating-point formatting on Windows
Flash0ver May 15, 2025
9a09832
Merge branch 'main' into feat/logs-initial-api
Flash0ver May 21, 2025
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Unreleased

### Features

- Add experimental support for _Sentry Structured Logging_ via `SentrySdk.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158))
## 5.8.0

### Features
Expand Down
18 changes: 18 additions & 0 deletions samples/Sentry.Samples.Console.Basic/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@

// This option tells Sentry to capture 100% of traces. You still need to start transactions and spans.
options.TracesSampleRate = 1.0;

// This option enables the (experimental) Sentry Logs.
options.EnableLogs = true;
options.SetBeforeSendLog(static log =>
{
if (log.TryGetAttribute("suppress", out bool attribute) && attribute)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (log.TryGetAttribute("suppress", out bool attribute) && attribute)
// A demonstration of how you can drop logs based on some attribute they have
if (log.TryGetAttribute("suppress", out bool attribute) && attribute)

{
return null;
}

// Drop logs with level Info
return log.Level is SentryLogLevel.Info ? null : log;
});
});

// This starts a new transaction and attaches it to the scope.
Expand All @@ -56,6 +69,7 @@ async Task FirstFunction()
var httpClient = new HttpClient(messageHandler, true);
var html = await httpClient.GetStringAsync("https://example.com/");
WriteLine(html);
SentrySdk.Logger.LogInfo("HTTP Request completed.");
}

async Task SecondFunction()
Expand All @@ -75,6 +89,8 @@ async Task SecondFunction()
// This is an example of capturing a handled exception.
SentrySdk.CaptureException(exception);
span.Finish(exception);

SentrySdk.Logger.LogError("Error with message: {0}", [exception.Message], static log => log.SetAttribute("method", nameof(SecondFunction)));
}

span.Finish();
Expand All @@ -88,6 +104,8 @@ async Task ThirdFunction()
// Simulate doing some work
await Task.Delay(100);

SentrySdk.Logger.LogFatal("Crash imminent!", [], static log => log.SetAttribute("suppress", true));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Odd that we must pass an empty array as a second argument.
There's no way we can set up the overloads in a way that this is not needed?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might be able to have an overload that doesn't take the second parameters argument right?


// This is an example of an unhandled exception. It will be captured automatically.
throw new InvalidOperationException("Something happened that crashed the app!");
}
Expand Down
3 changes: 3 additions & 0 deletions src/Sentry/BindableSentryOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ internal partial class BindableSentryOptions
public string? Distribution { get; set; }
public string? Environment { get; set; }
public string? Dsn { get; set; }
[Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)]
public bool? EnableLogs { get; set; }
public int? MaxQueueItems { get; set; }
public int? MaxCacheItems { get; set; }
public TimeSpan? ShutdownTimeout { get; set; }
Expand Down Expand Up @@ -68,6 +70,7 @@ public void ApplyTo(SentryOptions options)
options.Distribution = Distribution ?? options.Distribution;
options.Environment = Environment ?? options.Environment;
options.Dsn = Dsn ?? options.Dsn;
options.EnableLogs = EnableLogs ?? options.EnableLogs;
options.MaxQueueItems = MaxQueueItems ?? options.MaxQueueItems;
options.MaxCacheItems = MaxCacheItems ?? options.MaxCacheItems;
options.ShutdownTimeout = ShutdownTimeout ?? options.ShutdownTimeout;
Expand Down
22 changes: 22 additions & 0 deletions src/Sentry/Extensibility/DiagnosticLoggerExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ internal static void LogDebug<TArg, TArg2>(
TArg2 arg2)
=> options.DiagnosticLogger?.LogIfEnabled(SentryLevel.Debug, null, message, arg, arg2);

/// <summary>
/// Log a debug message.
/// </summary>
public static void LogDebug<TArg, TArg2, TArg3>(
this IDiagnosticLogger logger,
string message,
TArg arg,
TArg2 arg2,
TArg3 arg3)
=> logger.LogIfEnabled(SentryLevel.Debug, null, message, arg, arg2, arg3);

/// <summary>
/// Log a debug message.
/// </summary>
Expand Down Expand Up @@ -233,6 +244,17 @@ internal static void LogWarning<TArg, TArg2>(
TArg2 arg2)
=> options.DiagnosticLogger?.LogIfEnabled(SentryLevel.Warning, null, message, arg, arg2);

/// <summary>
/// Log a warning message.
/// </summary>
public static void LogWarning<TArg, TArg2, TArg3>(
this IDiagnosticLogger logger,
string message,
TArg arg,
TArg2 arg2,
TArg3 arg3)
=> logger.LogIfEnabled(SentryLevel.Warning, null, message, arg, arg2, arg3);

/// <summary>
/// Log a warning message.
/// </summary>
Expand Down
8 changes: 8 additions & 0 deletions src/Sentry/Extensibility/DisabledHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public class DisabledHub : IHub, IDisposable

private DisabledHub()
{
Logger = SentryStructuredLogger.CreateDisabled(this);
}

/// <summary>
Expand Down Expand Up @@ -228,4 +229,11 @@ public void CaptureUserFeedback(UserFeedback userFeedback)
/// No-Op.
/// </summary>
public SentryId LastEventId => SentryId.Empty;

/// <summary>
/// Disabled Logger.
/// <para>This API is experimental and it may change in the future.</para>
/// </summary>
[Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)]
public SentryStructuredLogger Logger { get; }
}
7 changes: 7 additions & 0 deletions src/Sentry/Extensibility/HubAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ private HubAdapter() { }
/// </summary>
public SentryId LastEventId { [DebuggerStepThrough] get => SentrySdk.LastEventId; }

/// <summary>
/// Forwards the call to <see cref="SentrySdk"/>.
/// <para>This API is experimental and it may change in the future.</para>
/// </summary>
[Experimental(DiagnosticId.ExperimentalFeature)]
public SentryStructuredLogger Logger { [DebuggerStepThrough] get => SentrySdk.Logger; }

/// <summary>
/// Forwards the call to <see cref="SentrySdk"/>.
/// </summary>
Expand Down
14 changes: 14 additions & 0 deletions src/Sentry/IHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ public interface IHub : ISentryClient, ISentryScopeManager
/// </summary>
public SentryId LastEventId { get; }

/// <summary>
/// Creates and sends logs to Sentry.
/// <para>This API is experimental and it may change in the future.</para>
/// </summary>
/// <remarks>
/// Available options:
/// <list type="bullet">
/// <item><see cref="Sentry.SentryOptions.EnableLogs"/></item>
/// <item><see cref="Sentry.SentryOptions.SetBeforeSendLog(System.Func{SentryLog, SentryLog})"/></item>
/// </list>
/// </remarks>
[Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)]
public SentryStructuredLogger Logger { get; }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just thinking about testing of the Hub classes in general, the Logger is kind of a dependency of the hub and currently it's not very easy to inject/mock.

Maybe worth creating an ISentryStructuredLogger interface and having this member be of type ISentryStructuredLogger rather than SentryStructuredLogger. A mock Logger could then optionally be injected via the constructor for test purposes.


/// <summary>
/// Starts a transaction.
/// </summary>
Expand Down
2 changes: 0 additions & 2 deletions src/Sentry/Infrastructure/DiagnosticId.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ namespace Sentry.Infrastructure;

internal static class DiagnosticId
{
#if NET5_0_OR_GREATER
/// <summary>
/// Indicates that the feature is experimental and may be subject to change or removal in future versions.
/// </summary>
internal const string ExperimentalFeature = "SENTRY0001";
#endif
}
5 changes: 5 additions & 0 deletions src/Sentry/Internal/Hub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ internal Hub(
PushScope();
}

Logger = new SentryStructuredLogger(this, ScopeManager, options, _clock);

#if MEMORY_DUMP_SUPPORTED
if (options.HeapDumpOptions is not null)
{
Expand Down Expand Up @@ -745,4 +747,7 @@ public void Dispose()
}

public SentryId LastEventId => CurrentScope.LastEventId;

[Experimental(DiagnosticId.ExperimentalFeature)]
public SentryStructuredLogger Logger { get; }
}
16 changes: 16 additions & 0 deletions src/Sentry/Protocol/Envelopes/Envelope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,22 @@ internal static Envelope FromClientReport(ClientReport clientReport)
return new Envelope(header, items);
}

// TODO: This is temporary. We don't expect single log messages to become an envelope by themselves since batching is needed
[Experimental(DiagnosticId.ExperimentalFeature)]
internal static Envelope FromLog(SentryLog log)
{
//TODO: allow batching Sentry logs
//see https://github.com/getsentry/sentry-dotnet/issues/4132
var header = DefaultHeader;

var items = new[]
{
EnvelopeItem.FromLog(log)
};

return new Envelope(header, items);
}

private static async Task<IReadOnlyDictionary<string, object?>> DeserializeHeaderAsync(
Stream stream,
CancellationToken cancellationToken = default)
Expand Down
16 changes: 16 additions & 0 deletions src/Sentry/Protocol/Envelopes/EnvelopeItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public sealed class EnvelopeItem : ISerializable, IDisposable
internal const string TypeValueProfile = "profile";
internal const string TypeValueMetric = "statsd";
internal const string TypeValueCodeLocations = "metric_meta";
internal const string TypeValueLog = "log";

private const string LengthKey = "length";
private const string FileNameKey = "filename";
Expand Down Expand Up @@ -370,6 +371,21 @@ internal static EnvelopeItem FromClientReport(ClientReport report)
return new EnvelopeItem(header, new JsonSerializable(report));
}

[Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)]
internal static EnvelopeItem FromLog(SentryLog log)
{
//TODO: allow batching Sentry logs
//see https://github.com/getsentry/sentry-dotnet/issues/4132
var header = new Dictionary<string, object?>(3, StringComparer.Ordinal)
{
[TypeKey] = TypeValueLog,
["item_count"] = 1,
["content_type"] = "application/vnd.sentry.items.log+json",
};

return new EnvelopeItem(header, new JsonSerializable(log));
}

private static async Task<Dictionary<string, object?>> DeserializeHeaderAsync(
Stream stream,
CancellationToken cancellationToken = default)
Expand Down
107 changes: 107 additions & 0 deletions src/Sentry/Protocol/SentryAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
namespace Sentry.Protocol;

[DebuggerDisplay(@"\{ Value = {Value}, Type = {Type} \}")]
internal readonly struct SentryAttribute
{
public SentryAttribute(object value, string type)
{
Value = value;
Type = type;
}

public object Value { get; }
public string Type { get; }
}

internal static class SentryAttributeSerializer
{
internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, SentryAttribute attribute)
{
Debug.Assert(attribute.Type is not null);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this assert on all calls?
it's odd to have it here but no on the others
Or only on the method these all fall into: WriteAttributeValue?

writer.WritePropertyName(propertyName);
WriteAttributeValue(writer, attribute.Value, attribute.Type);
}

internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, object value, string type)
{
writer.WritePropertyName(propertyName);
WriteAttributeValue(writer, value, type);
}

internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, object value)
{
writer.WritePropertyName(propertyName);
WriteAttributeValue(writer, value);
}

private static void WriteAttributeValue(Utf8JsonWriter writer, object value, string type)
{
writer.WriteStartObject();

if (type == "string")
{
writer.WriteString("value", (string)value);
writer.WriteString("type", type);
}
else if (type == "boolean")
{
writer.WriteBoolean("value", (bool)value);
writer.WriteString("type", type);
}
else if (type == "integer")
{
writer.WriteNumber("value", (long)value);
writer.WriteString("type", type);
}
else if (type == "double")
{
writer.WriteNumber("value", (double)value);
writer.WriteString("type", type);
}
else
{
writer.WriteString("value", value.ToString());
writer.WriteString("type", "string");
}

writer.WriteEndObject();
}

private static void WriteAttributeValue(Utf8JsonWriter writer, object value)
{
writer.WriteStartObject();

if (value is string str)
{
writer.WriteString("value", str);
writer.WriteString("type", "string");
}
else if (value is bool boolean)
{
writer.WriteBoolean("value", boolean);
writer.WriteString("type", "boolean");
}
else if (value is int int32)
{
writer.WriteNumber("value", int32);
writer.WriteString("type", "integer");
}
else if (value is long int64)
{
writer.WriteNumber("value", int64);
writer.WriteString("type", "integer");
}
else if (value is double float64)
{
writer.WriteNumber("value", float64);
writer.WriteString("type", "double");
}
else
{
writer.WriteString("value", value.ToString());
writer.WriteString("type", "string");
}

writer.WriteEndObject();
}
}
Loading
Loading