Skip to content
Merged
Next Next commit
feat(logs): initial API for Sentry Logs (#4158)
  • Loading branch information
Flash0ver authored Jun 25, 2025
commit 8d3536206ff48dd9575b0c9e3c3eb024b1151f61
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Features

- Add experimental support for [Sentry Structured Logging](https://docs.sentry.io/product/explore/logs/) via `SentrySdk.Experimental.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158))

## 5.11.2

### Fixes
Expand Down
20 changes: 20 additions & 0 deletions samples/Sentry.Samples.Console.Basic/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* - Error Monitoring (both handled and unhandled exceptions)
* - Performance Tracing (Transactions / Spans)
* - Release Health (Sessions)
* - Logs
* - MSBuild integration for Source Context (see the csproj)
*
* For more advanced features of the SDK, see Sentry.Samples.Console.Customized.
Expand Down Expand Up @@ -35,6 +36,20 @@

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

// This option enables Sentry Logs created via SentrySdk.Logger.
options.Experimental.EnableLogs = true;
options.Experimental.SetBeforeSendLog(static log =>
{
// A demonstration of how you can drop logs based on some attribute they have
if (log.TryGetAttribute("suppress", out var attribute) && attribute is true)
{
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 @@ -58,6 +73,7 @@ async Task FirstFunction()
var httpClient = new HttpClient(messageHandler, true);
var html = await httpClient.GetStringAsync("https://example.com/");
WriteLine(html);
SentrySdk.Experimental.Logger.LogInfo("HTTP Request completed.");
}

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

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

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

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

// This is an example of an unhandled exception. It will be captured automatically.
throw new InvalidOperationException("Something happened that crashed the app!");
}
Expand Down
11 changes: 11 additions & 0 deletions src/Sentry/BindableSentryOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ internal partial class BindableSentryOptions
public bool? EnableSpotlight { get; set; }
public string? SpotlightUrl { get; set; }

[Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)]
public BindableSentryExperimentalOptions Experimental { get; set; } = new();

[Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)]
internal sealed class BindableSentryExperimentalOptions
{
public bool? EnableLogs { get; set; }
}

public void ApplyTo(SentryOptions options)
{
options.IsGlobalModeEnabled = IsGlobalModeEnabled ?? options.IsGlobalModeEnabled;
Expand Down Expand Up @@ -100,6 +109,8 @@ public void ApplyTo(SentryOptions options)
options.EnableSpotlight = EnableSpotlight ?? options.EnableSpotlight;
options.SpotlightUrl = SpotlightUrl ?? options.SpotlightUrl;

options.Experimental.EnableLogs = Experimental.EnableLogs ?? options.Experimental.EnableLogs;

#if ANDROID
Android.ApplyTo(options.Android);
Native.ApplyTo(options.Native);
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
7 changes: 7 additions & 0 deletions src/Sentry/Extensibility/DisabledHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -254,4 +254,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 => DisabledSentryStructuredLogger.Instance;
}
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.Experimental.Logger; }

/// <summary>
/// Forwards the call to <see cref="SentrySdk"/>.
/// </summary>
Expand Down
12 changes: 12 additions & 0 deletions src/Sentry/HubExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -259,4 +259,16 @@ internal static ITransactionTracer StartTransaction(
var transaction = hub.GetTransaction();
return transaction?.IsSampled == true ? transaction : null;
}

internal static Scope? GetScope(this IHub hub)
{
if (hub is Hub fullHub)
{
return fullHub.ScopeManager.GetCurrent().Key;
}

Scope? current = null;
hub.ConfigureScope(scope => current = scope);
return current;
}
}
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.SentryExperimentalOptions.EnableLogs"/></item>
/// <item><see cref="Sentry.SentryOptions.SentryExperimentalOptions.SetBeforeSendLog(System.Func{SentryLog, SentryLog})"/></item>
/// </list>
/// </remarks>
[Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)]
public SentryStructuredLogger Logger { get; }

/// <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
}
79 changes: 79 additions & 0 deletions src/Sentry/Internal/DefaultSentryStructuredLogger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using Sentry.Extensibility;
using Sentry.Infrastructure;
using Sentry.Protocol.Envelopes;

namespace Sentry.Internal;

internal sealed class DefaultSentryStructuredLogger : SentryStructuredLogger
{
private readonly IHub _hub;
private readonly SentryOptions _options;
private readonly ISystemClock _clock;

internal DefaultSentryStructuredLogger(IHub hub, SentryOptions options, ISystemClock clock)
{
Debug.Assert(options is { Experimental.EnableLogs: true });

_hub = hub;
_options = options;
_clock = clock;
}

private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action<SentryLog>? configureLog)
{
var timestamp = _clock.GetUtcNow();
var traceHeader = _hub.GetTraceHeader() ?? SentryTraceHeader.Empty;

string message;
try
{
message = string.Format(CultureInfo.InvariantCulture, template, parameters ?? []);
}
catch (FormatException e)
{
_options.DiagnosticLogger?.LogError(e, "Template string does not match the provided argument. The Log will be dropped.");
return;
}

SentryLog log = new(timestamp, traceHeader.TraceId, level, message)
{
Template = template,
Parameters = ImmutableArray.Create(parameters),
ParentSpanId = traceHeader.SpanId,
};

try
{
configureLog?.Invoke(log);
}
catch (Exception e)
{
_options.DiagnosticLogger?.LogError(e, "The configureLog callback threw an exception. The Log will be dropped.");
return;
}

var scope = _hub.GetScope();
log.SetDefaultAttributes(_options, scope?.Sdk ?? SdkVersion.Instance);

var configuredLog = log;
if (_options.Experimental.BeforeSendLogInternal is { } beforeSendLog)
{
try
{
configuredLog = beforeSendLog.Invoke(log);
}
catch (Exception e)
{
_options.DiagnosticLogger?.LogError(e, "The BeforeSendLog callback threw an exception. The Log will be dropped.");
return;
}
}

if (configuredLog is not null)
{
//TODO: enqueue in Batch-Processor / Background-Worker
// see https://github.com/getsentry/sentry-dotnet/issues/4132
_ = _hub.CaptureEnvelope(Envelope.FromLog(configuredLog));
}
}
}
15 changes: 15 additions & 0 deletions src/Sentry/Internal/DisabledSentryStructuredLogger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Sentry.Internal;

internal sealed class DisabledSentryStructuredLogger : SentryStructuredLogger
{
internal static DisabledSentryStructuredLogger Instance { get; } = new DisabledSentryStructuredLogger();

internal DisabledSentryStructuredLogger()
{
}

private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action<SentryLog>? configureLog)
{
// disabled
}
}
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 = SentryStructuredLogger.Create(this, options, _clock);

#if MEMORY_DUMP_SUPPORTED
if (options.HeapDumpOptions is not null)
{
Expand Down Expand Up @@ -800,4 +802,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
Loading
Loading