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

Merged
merged 90 commits into from
Jun 25, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
90 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
f133118
Merge branch 'main' into feat/logs-initial-api
Flash0ver Jun 3, 2025
72c9a93
ref: make SentryStructuredLogger abstract
Flash0ver Jun 4, 2025
c97f4ad
docs: add comment to sample usage of SetBeforeSendLog
Flash0ver Jun 5, 2025
a9eea90
ref: clarify intent of usages of Debug.Assert
Flash0ver Jun 5, 2025
8bd0ed2
docs: improve XML comments
Flash0ver Jun 5, 2025
51892de
test: range of Severity-Number specification
Flash0ver Jun 5, 2025
acc8995
test: GetValuesAsUnderlyingType of the new enum
Flash0ver Jun 5, 2025
6bd4c96
ref: move Log options to Experimental section
Flash0ver Jun 5, 2025
f673d1e
ref: move Logger to Experimental section of SDK
Flash0ver Jun 5, 2025
daafd7f
test: add Hub tests
Flash0ver Jun 5, 2025
62ee5d5
Merge branch 'feat/logs' into feat/logs-initial-api
Flash0ver Jun 6, 2025
6a54203
test: update verified public API
Flash0ver Jun 6, 2025
479cab8
docs: update CHANGELOG.md
Flash0ver Jun 6, 2025
97a87f8
Merge branch 'main' into feat/logs-initial-api
jamescrosswell Jun 9, 2025
0467449
Merge branch 'feat/logs' into feat/logs-initial-api
Flash0ver Jun 16, 2025
54062d2
ref: reuse Disabled-Instance when Structured-Logging is not enabled
Flash0ver Jun 16, 2025
7107bce
ref: remove Enabled-checks on Default-Logger
Flash0ver Jun 16, 2025
c0a1cd5
ref: rename DisabledSentryStructuredLogger to NoOpSentryStructuredLogger
Flash0ver Jun 16, 2025
45b8687
ref: make `BindableSentryOptions.Experimental` internal
Flash0ver Jun 16, 2025
3192534
Revert "ref: make `BindableSentryOptions.Experimental` internal"
Flash0ver Jun 16, 2025
b8bcea6
docs: Update CHANGELOG.md
Flash0ver Jun 16, 2025
d4c82a2
Revert "ref: rename DisabledSentryStructuredLogger to NoOpSentryStruc…
Flash0ver Jun 18, 2025
9193a96
ref: replace use of ScopeManager with TraceHeader
Flash0ver Jun 18, 2025
7cb4043
test: remove Skip as we no longer test net48 against non-Windows
Flash0ver Jun 18, 2025
e3ca5b5
feat: support more numeric types
Flash0ver Jun 20, 2025
afb135e
feat: support char attributes
Flash0ver Jun 20, 2025
7f675aa
fix: build error targeting .NET Standard 2.0 and .NET Framework
Flash0ver Jun 20, 2025
750a388
fix: exception when passing null as message parameter
Flash0ver Jun 20, 2025
9f62d3b
test: add Attributes-To-Json test
Flash0ver Jun 20, 2025
5b00c21
fix: missing type on .NET Framework
Flash0ver Jun 24, 2025
6d17918
feat: support Attribute-Types from spec
Flash0ver Jun 24, 2025
7e2c57b
ref: clarify internal identifiers
Flash0ver Jun 24, 2025
7115c42
test: update approved API
Flash0ver Jun 24, 2025
cd5246b
test: fix incorrect expectation
Flash0ver Jun 24, 2025
baf5569
Merge branch 'feat/logs' into feat/logs-initial-api
Flash0ver Jun 24, 2025
6e13e95
feat: use "wrapping" SDK's Name and Version
Flash0ver Jun 25, 2025
0a9a3b1
fix: Get-Attribute-API
Flash0ver Jun 25, 2025
934fb36
Merge branch 'feat/logs' into feat/logs-initial-api
Flash0ver Jun 25, 2025
2c1608e
docs: update CHANGELOG
Flash0ver Jun 25, 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
14 changes: 14 additions & 0 deletions samples/Sentry.Samples.Console.Basic/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

using System.Net.Http;
using Sentry.Experimental;
using static System.Console;

// Initialize the Sentry SDK. (It is not necessary to dispose it.)
Expand All @@ -35,6 +36,18 @@
options.TracesSampleRate = 1.0;
});

#pragma warning disable SENTRY0002
SentrySdk.Logger.Trace("Hello, World!", null, log => log.SetAttribute("trace", "trace"));
SentrySdk.Logger.Debug("Hello, .NET!", null, log => log.SetAttribute("trace", "trace"));
SentrySdk.Logger.Info("Information", null, log => log.SetAttribute("trace", "trace"));
SentrySdk.Logger.Warn("Warning with one {0}", ["parameter"], log => log.SetAttribute("trace", "trace"));
SentrySdk.Logger.Error("Error with {0} {1}", [2, "parameters"], log => log.SetAttribute("trace", "trace"));
SentrySdk.Logger.Fatal("Fatal {0} and {1}", [true, false], log => log.SetAttribute("trace", "trace"));
#pragma warning restore SENTRY0002

await Task.Delay(5_000);

/*
// This starts a new transaction and attaches it to the scope.
var transaction = SentrySdk.StartTransaction("Program Main", "function");
SentrySdk.ConfigureScope(scope => scope.Transaction = transaction);
Expand Down Expand Up @@ -96,3 +109,4 @@ async Task ThirdFunction()
span.Finish();
}
}
*/
155 changes: 155 additions & 0 deletions src/Sentry/Experimental/SentryLog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
using Sentry.Extensibility;
using Sentry.Infrastructure;
using Sentry.Internal.Extensions;

#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member

namespace Sentry.Experimental;

[Experimental(DiagnosticId.ExperimentalSentryLogs)]
public sealed class SentryLog : ISentryJsonSerializable
{
private Dictionary<string, ValueTypePair>? _attributes;
private int _severityNumber = -1;

[SetsRequiredMembers]
internal SentryLog(SentrySeverity level, string message, object[]? parameters = null)
{
Timestamp = DateTimeOffset.UtcNow;
TraceId = SentryId.Empty;
Level = level;
Message = message;
Parameters = parameters;
}

public required DateTimeOffset Timestamp { get; init; }

public required SentryId TraceId { get; init; }

public SentrySeverity Level
{
get => SentrySeverityExtensions.FromSeverityNumber(_severityNumber);
set => _severityNumber = SentrySeverityExtensions.ToSeverityNumber(value);
}

public required string Message { get; init; }

//public Dictionary<string, object>? Attributes { get { return _attributes; } }

public string? Template { get; init; }

public object[]? Parameters { get; init; }

public required int SeverityNumber
{
get => _severityNumber;
set
{
//
SentrySeverityExtensions.ThrowIfOutOfRange(value);
_severityNumber = value;
}
}

public void SetAttribute(string key, string value)
{
_attributes ??= new Dictionary<string, ValueTypePair>();
_attributes[key] = new ValueTypePair(value, "string");
}

public void SetAttribute(string key, bool value)
{
_attributes ??= new Dictionary<string, ValueTypePair>();
_attributes[key] = new ValueTypePair(value, "boolean");
}

public void SetAttribute(string key, int value)
{
_attributes ??= new Dictionary<string, ValueTypePair>();
_attributes[key] = new ValueTypePair(value, "integer");
}

public void SetAttribute(string key, double value)
{
_attributes ??= new Dictionary<string, ValueTypePair>();
_attributes[key] = new ValueTypePair(value, "double");
}

public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger)
{
_attributes = new Dictionary<string, ValueTypePair>
{
{ "sentry.environment", new ValueTypePair("production", "string")},
{ "sentry.release", new ValueTypePair("1.0.0", "string")},
{ "sentry.trace.parent_span_id", new ValueTypePair("b0e6f15b45c36b12", "string")},
};

writer.WriteStartObject();
writer.WriteStartArray("items");
writer.WriteStartObject();

writer.WriteNumber("timestamp", Timestamp.ToUnixTimeSeconds());
writer.WriteString("trace_id", TraceId);
writer.WriteString("level", Level.ToLogString());
writer.WriteString("body", Message);

writer.WritePropertyName("attributes");
writer.WriteStartObject();

if (Template is not null)
{
writer.WriteSerializable("sentry.message.template", new ValueTypePair(Template, "string"), null);
}

if (Parameters is not null)
{
for (var index = 0; index < Parameters.Length; index++)
{
var type = "string";
writer.WriteSerializable($"sentry.message.parameters.{index}", new ValueTypePair(Parameters[index], type), null);
}
}

if (_attributes is not null)
{
foreach (var attribute in _attributes)
{
writer.WriteSerializable(attribute.Key, attribute.Value, null);
}
}

writer.WriteEndObject();

if (SeverityNumber != -1)
{
writer.WriteNumber("severity_number", SeverityNumber);
}

writer.WriteEndObject();
writer.WriteEndArray();
writer.WriteEndObject();
}
}

//TODO: remove? perhaps a simple System.ValueTuple`2 suffices
internal readonly struct ValueTypePair : ISentryJsonSerializable
{
public ValueTypePair(object value, string type)
{
Value = value.ToString()!;
Type = type;
}

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

public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger)
{
writer.WriteStartObject();

writer.WriteString("value", Value);
writer.WriteString("type", Type);

writer.WriteEndObject();
}
}
74 changes: 74 additions & 0 deletions src/Sentry/Experimental/SentrySeverity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using Sentry.Infrastructure;

#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member

namespace Sentry.Experimental;

//TODO: QUESTION: not sure about the name
// this is a bit different to Sentry.SentryLevel and Sentry.BreadcrumbLevel
[Experimental(DiagnosticId.ExperimentalSentryLogs)]
public enum SentrySeverity : short
{
Trace,
Debug,
Info,
Warn,
Error,
Fatal,
}

[Experimental(DiagnosticId.ExperimentalSentryLogs)]
internal static class SentrySeverityExtensions
{
internal static string ToLogString(this SentrySeverity severity)
{
return severity switch
{
SentrySeverity.Trace => "trace",
SentrySeverity.Debug => "debug",
SentrySeverity.Info => "info",
SentrySeverity.Warn => "warn",
SentrySeverity.Error => "error",
SentrySeverity.Fatal => "fatal",
_ => throw new ArgumentOutOfRangeException(nameof(severity), severity, null),
};
}

internal static SentrySeverity FromSeverityNumber(int severityNumber)
{
ThrowIfOutOfRange(severityNumber);

return severityNumber switch
{
>= 1 and <= 4 => SentrySeverity.Trace,
>= 5 and <= 8 => SentrySeverity.Debug,
>= 9 and <= 12 => SentrySeverity.Info,
>= 13 and <= 16 => SentrySeverity.Warn,
>= 17 and <= 20 => SentrySeverity.Error,
>= 21 and <= 24 => SentrySeverity.Fatal,
_ => throw new UnreachableException(),
};
}

internal static int ToSeverityNumber(SentrySeverity severity)
{
return severity switch
{
SentrySeverity.Trace => 1,
SentrySeverity.Debug => 5,
SentrySeverity.Info => 9,
SentrySeverity.Warn => 13,
SentrySeverity.Error => 17,
SentrySeverity.Fatal => 21,
_ => throw new ArgumentOutOfRangeException(nameof(severity), severity, null)
};
}

internal static void ThrowIfOutOfRange(int severityNumber)
{
if (severityNumber is < 1 or > 24)
{
throw new ArgumentOutOfRangeException(nameof(severityNumber), severityNumber, "SeverityNumber must be between 1 (inclusive) and 24 (inclusive).");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#if !NET8_0_OR_GREATER
// ReSharper disable CheckNamespace
// ReSharper disable ConvertToPrimaryConstructor
namespace System.Diagnostics.CodeAnalysis;

[AttributeUsage(AttributeTargets.Assembly |
AttributeTargets.Module |
AttributeTargets.Class |
AttributeTargets.Struct |
AttributeTargets.Enum |
AttributeTargets.Constructor |
AttributeTargets.Method |
AttributeTargets.Property |
AttributeTargets.Field |
AttributeTargets.Event |
AttributeTargets.Interface |
AttributeTargets.Delegate, Inherited = false)]
internal sealed class ExperimentalAttribute : Attribute
{
public ExperimentalAttribute(string diagnosticId)
{
DiagnosticId = diagnosticId;
}

public string DiagnosticId { get; }

public string? UrlFormat { get; set; }
}
#endif
22 changes: 22 additions & 0 deletions src/Sentry/Experimental/System.Diagnostics.UnreachableException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#if !NET7_0_OR_GREATER
// ReSharper disable CheckNamespace
namespace System.Diagnostics;

internal sealed class UnreachableException : Exception
{
public UnreachableException()
: base("The program executed an instruction that was thought to be unreachable.")
{
}

public UnreachableException(string? message)
: base(message ?? "The program executed an instruction that was thought to be unreachable.")
{
}

public UnreachableException(string? message, Exception? innerException)
: base(message ?? "The program executed an instruction that was thought to be unreachable.", innerException)
{
}
}
#endif
9 changes: 9 additions & 0 deletions src/Sentry/Infrastructure/DiagnosticId.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,13 @@ internal static class DiagnosticId
/// </summary>
internal const string ExperimentalFeature = "SENTRY0001";
#endif

//TODO: QUESTION: Should we re-use the above for all [Experimental] features or have one ID per experimental feature?
internal const string ExperimentalSentryLogs = "SENTRY0002";
}

//TODO: not sure about this type name
internal static class UrlFormats
{
internal const string ExperimentalSentryLogs = "https://github.com/getsentry/sentry-dotnet/issues/4132";
}
11 changes: 11 additions & 0 deletions src/Sentry/Internal/Extensions/JsonExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,17 @@ public static void WriteSerializable(
writer.WriteSerializableValue(value, logger);
}

public static void WriteSerializable<TValue>(
this Utf8JsonWriter writer,
string propertyName,
TValue value,
IDiagnosticLogger? logger)
where TValue : struct, ISentryJsonSerializable
{
writer.WritePropertyName(propertyName);
value.WriteTo(writer, logger);
}

public static void WriteDynamicValue(
this Utf8JsonWriter writer,
object? value,
Expand Down
14 changes: 14 additions & 0 deletions src/Sentry/Protocol/Envelopes/Envelope.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Sentry.Experimental;
using Sentry.Extensibility;
using Sentry.Infrastructure;
using Sentry.Internal;
Expand Down Expand Up @@ -445,6 +446,19 @@ internal static Envelope FromClientReport(ClientReport clientReport)
return new Envelope(header, items);
}

[Experimental(DiagnosticId.ExperimentalSentryLogs)]
internal static Envelope FromLog(SentryLog log)
{
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
Loading
Loading